yalasuo

系统前后端解析---文件夹内上传方法

deepseek_html_20260119_a74a8a.html

deepseek_html_20260119_a748888a8a.html

主要修改说明:
1. 新增 parentId 支持
在 PresignedUploadOptionsDto 接口中添加了 parentId?: string 

在 RedisPresignedData 接口中添加了 parentId?: string 字段

2. 新增 generateFolderAwareFileKey 方法
根据 parentId 生成不同的文件路径格式:uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename

如果没有 parentId,则使用原有的路径格式

3. 新增文件夹相关方法
generateFolderUploadUrl(): 专门用于生成文件夹内上传URL的方法

batchGenerateFolderUploadUrls(): 批量生成文件夹内上传URL

getFolderUploads(): 获取文件夹内已上传的文件列表

validateFolderUpload(): 验证文件夹上传权限

getFolderUploadStats(): 获取文件夹上传统计信息

testFolderUpload(): 测试文件夹上传功能

4. 修改现有方法以支持 parentId
修改 generatePresignedUrl(): 使用新的文件夹感知文件路径生成

修改 completePresignedUpload(): 保存 parentId 到数据库

修改 saveToRedis(): 保存 parentId 到Redis

修改 generateUploadUrl() 和 batchGenerateUploadUrls(): 支持 parentId 参数

修改多个统计和查询方法:返回 parentId 信息

5. 路径格式
根目录上传:uploads/{userId}/YYYY/MM/DD/timestamp-random-filename

文件夹内上传:uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename

6. 兼容性
向后兼容:原有的上传功能保持不变

新增功能:可以通过传递 parentId 参数实现文件夹内上传

这样修改后,前端可以通过传递 parentId 参数来指定上传到哪个文件夹,后端会根据 parentId 生成相应的文件存储路径,并在数据库中记录文件的父文件夹关系。

后端接口文档 

deepseek_html_20260119_f67d33.html

// src/modules/presigned/presigned.service.ts
import { Injectable, Logger, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { MinioService } from '../minio/minio.service';
import { RedisService } from '../redis/redis.service';
import { File } from '../../entities/File';
import { FileStatus } from '../../common/enums/file-status.enum';
import { CompletePresignedUploadDto } from './dto/complete-presigned-upload.dto';
import { PresignedUploadOptionsDto } from './dto/presigned-upload-options.dto';

interface RedisPresignedData {
  userId: string;
  filename: string;
  contentType: string;
  fileKey: string;
  expiresAt: number;
  generatedAt: number;
  parentId?: string; // ✅ 新增:记录父文件夹ID
}

@Injectable()
export class PresignedService {
  private readonly logger = new Logger(PresignedService.name);
  private readonly bucketName: string = 'smart-upload';
  private s3Client: S3Client;
  private readonly minioEndpoint: string = 'http://www.fox360.cn:9002';

  constructor(
    @InjectRepository(File)
    private readonly fileRepository: Repository<File>,
    private readonly minioService: MinioService,
    private readonly configService: ConfigService,
    @Inject(RedisService)
    private readonly redisService: RedisService,
  ) {
    this.initializeS3Client();
    this.logger.log(`🚀 PresignedService初始化完成`);
    this.logger.log(`   存储桶: ${this.bucketName}`);
    this.logger.log(`   端点: ${this.minioEndpoint}`);
  }

  /**
   * 初始化AWS S3客户端
   */
  private initializeS3Client() {
    try {
      this.s3Client = new S3Client({
        endpoint: this.minioEndpoint,
        region: 'us-east-1',
        credentials: {
          accessKeyId: 'minioadmin',
          secretAccessKey: 'minioadmin',
        },
        forcePathStyle: true,
      });
      this.logger.log(`✅ S3客户端初始化完成,端点: ${this.minioEndpoint}`);
    } catch (error) {
      this.logger.error(`❌ S3客户端初始化失败: ${error.message}`);
    }
  }

  /**
   * ✅【新增】生成文件夹感知的文件路径
   * 支持根据parentId生成不同的文件路径
   */
  private generateFolderAwareFileKey(
    userId: string,
    filename: string,
    parentId?: string,
    prefix?: string
  ): string {
    // 1. 清理文件名,替换特殊字符
    const safeFilename = filename.replace(/[^\w.\-]/g, '_');

    // 2. 生成时间戳和随机字符串
    const timestamp = Date.now();
    const randomStr = Math.random().toString(36).substring(2, 10);

    // 3. 处理前缀和父文件夹
    let cleanPrefix = '';
    if (parentId) {
      // 如果有父文件夹ID,使用文件夹路径
      cleanPrefix = `uploads/${userId}/folders/${parentId}`;
    } else if (prefix && prefix.trim()) {
      // 使用自定义前缀
      cleanPrefix = prefix.trim()
        .replace(/^\/+|\/+$/g, '')
        .replace(/\/\/+/g, '/');
    } else {
      // 默认路径
      cleanPrefix = `uploads/${userId}`;
    }

    // 4. 添加日期路径
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const day = String(now.getDate()).padStart(2, '0');
    
    // 5. 构建最终文件路径
    const fileKey = `${cleanPrefix}/${year}/${month}/${day}/${timestamp}-${randomStr}-${safeFilename}`;

    // 6. 最终清理
    const finalKey = fileKey.replace(/\/\/+/g, '/');

    this.logger.debug(`📁 文件夹感知文件路径生成:`, {
      userId,
      parentId,
      prefix,
      cleanPrefix,
      finalKey
    });

    return finalKey;
  }

  /**
   * 主方法:生成预签名URL(支持文件夹)
   */
  async generatePresignedUrl(
    userId: string,
    username: string,
    options: PresignedUploadOptionsDto,
    requestHost?: string
  ): Promise<any> {
    this.logger.log(`📤 为用户 ${username} 生成预签名URL: ${options.filename}`, {
      parentId: options.parentId,
      prefix: options.prefix
    });

    const timestamp = Date.now();
    const bucket = this.bucketName;
    const expiresIn = options.expiresIn || 3600;
    const contentType = options.contentType || 'application/octet-stream';

    // ✅【修改】使用新的文件夹感知文件路径生成方法
    const fileKey = this.generateFolderAwareFileKey(
      userId, 
      options.filename, 
      options.parentId, // 传入父文件夹ID
      options.prefix
    );

    this.logger.log(`📂 生成的文件路径: ${fileKey}`);

    try {
      // 使用AWS SDK生成预签名URL
      const command = new PutObjectCommand({
        Bucket: bucket,
        Key: fileKey,
        ContentType: contentType,
      });

      const presignedUrl = await getSignedUrl(this.s3Client, command, {
        expiresIn: expiresIn,
      });

      // 保存到Redis(包含parentId信息)
      await this.saveToRedis(userId, options, fileKey, expiresIn);

      // 构建返回结果
      return {
        success: true,
        data: {
          url: presignedUrl,
          originalUrl: presignedUrl,
          key: fileKey,
          uploadId: `upload-${timestamp}`,
          expiresIn: expiresIn,
          filename: options.filename,
          contentType: contentType,
          bucket: bucket,
          parentId: options.parentId, // 返回给前端
          publicUrl: `${this.minioEndpoint}/${bucket}/${fileKey}`,
          note: '支持文件夹内上传',
          expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
          debugInfo: {
            endpointUsed: this.minioEndpoint,
            requestHost,
            filePath: fileKey,
            hasParentId: !!options.parentId,
            pathCheck: fileKey.includes('//') ? '警告:路径包含双斜杠' : '正常'
          }
        },
        timestamp: new Date().toISOString()
      };

    } catch (error) {
      this.logger.error(`❌ 生成预签名URL失败: ${error.message}`);
      return await this.fallbackToMinioService(userId, username, options, fileKey, expiresIn);
    }
  }

  /**
   * ✅【修改】保存到Redis(支持parentId)
   */
  private async saveToRedis(
    userId: string,
    options: PresignedUploadOptionsDto,
    fileKey: string,
    expiresIn: number
  ) {
    const redisData: RedisPresignedData = {
      userId,
      filename: options.filename,
      contentType: options.contentType,
      fileKey,
      expiresAt: Date.now() + expiresIn * 1000,
      generatedAt: Date.now(),
      parentId: options.parentId, // ✅ 保存父文件夹ID
    };

    await this.redisService.set(
      `presigned:upload:${fileKey}`,
      redisData,
      expiresIn,
    );

    this.logger.debug(`💾 Redis保存成功: ${fileKey}`, { parentId: options.parentId });
  }

  /**
   * 使用AWS SDK生成预签名URL
   */
  private async generateAwsPresignedUrl(
    bucket: string,
    key: string,
    contentType: string,
    expiresIn: number
  ): Promise<string> {
    try {
      this.logger.log(`🔗 生成预签名URL - 桶: ${bucket}, 键: ${key}`);

      const command = new PutObjectCommand({
        Bucket: bucket,
        Key: key,
        ContentType: contentType,
      });

      const signedUrl = await getSignedUrl(this.s3Client, command, {
        expiresIn: expiresIn,
      });

      this.logger.log(`✅ AWS预签名URL生成成功: ${signedUrl.substring(0, 100)}...`);
      return signedUrl;
    } catch (error) {
      this.logger.error(`AWS SDK生成失败: ${error.message}`);
      throw error;
    }
  }

  /**
   * 降级方案:使用MinIO Service
   */
  private async fallbackToMinioService(
    userId: string,
    username: string,
    options: PresignedUploadOptionsDto,
    fileKey: string,
    expiresIn: number
  ): Promise<any> {
    this.logger.warn('⚠️ 使用MinIO Service降级方案');

    try {
      const minio = require('minio');
      const url = new URL(this.minioEndpoint);

      const tempClient = new minio.Client({
        endPoint: url.hostname,
        port: parseInt(url.port),
        useSSL: url.protocol === 'https:',
        accessKey: 'minioadmin',
        secretKey: 'minioadmin',
      });

      const presignedUrl = await tempClient.presignedPutObject(
        this.bucketName,
        fileKey,
        expiresIn
      );

      return {
        success: true,
        data: {
          url: presignedUrl,
          originalUrl: presignedUrl,
          key: fileKey,
          uploadId: `upload-${Date.now()}`,
          expiresIn: expiresIn,
          filename: options.filename,
          contentType: options.contentType,
          bucket: this.bucketName,
          parentId: options.parentId,
          note: 'MinIO Service降级方案',
        },
        timestamp: new Date().toISOString()
      };

    } catch (error) {
      this.logger.error(`❌ 降级方案也失败: ${error.message}`);

      // 最终方案:手动构建URL
      const dateStr = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
      const dateOnly = new Date().toISOString().split('T')[0];

      const fallbackUrl = `${this.minioEndpoint}/${this.bucketName}/${encodeURIComponent(fileKey).replace(/%2F/g, '/')}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F${dateOnly}%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=${dateStr}Z&X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=host`;

      return {
        success: false,
        data: {
          url: fallbackUrl,
          originalUrl: fallbackUrl,
          key: fileKey,
          uploadId: `upload-${Date.now()}`,
          expiresIn: expiresIn,
          filename: options.filename,
          contentType: options.contentType,
          bucket: this.bucketName,
          parentId: options.parentId,
          note: '最终降级:手动构建URL',
          warning: '所有签名方法都失败,URL可能无法使用',
        },
        timestamp: new Date().toISOString()
      };
    }
  }

  /**
   * ✅【修改】完成上传记录(支持parentId)
   */
  async completePresignedUpload(
    userId: string,
    completeDto: CompletePresignedUploadDto,
  ): Promise<{
    success: boolean;
    file?: File;
    message: string;
  }> {
    this.logger.log(`✅ 用户 ${userId} 完成预签名上传: ${completeDto.filename}`, {
      parentId: completeDto.parentId,
      fileKey: completeDto.fileKey
    });

    try {
      // 验证文件是否存在
      const fileExists = await this.minioService.fileExists(completeDto.fileKey);
      if (!fileExists) {
        return {
          success: false,
          message: '文件不存在于存储桶中'
        };
      }

      // 从Redis获取原始上传信息
      const redisData = await this.redisService.get<RedisPresignedData>(`presigned:upload:${completeDto.fileKey}`);
      
      // 清理Redis记录
      await this.redisService.del(`presigned:upload:${completeDto.fileKey}`);

      // 使用正确的File实体属性
      const file = this.fileRepository.create({
        filename: completeDto.filename,
        originalName: completeDto.filename,
        mimetype: completeDto.mimetype,
        size: completeDto.size,
        path: completeDto.fileKey,
        userId: userId,
        status: FileStatus.COMPLETED,
        storageProvider: 'minio',
        hash: completeDto.hash,
        etag: completeDto.etag,
        metadata: completeDto.metadata || {},
        uploadedSize: completeDto.size,
        chunks: 1,
        bucket: this.bucketName,
        parentId: completeDto.parentId || (redisData?.parentId), // ✅ 优先使用传入的parentId,其次用Redis中的
        isFolder: false,
      });

      const savedFile = await this.fileRepository.save(file);

      this.logger.log(`📝 文件记录保存成功: ${savedFile.id}`, {
        parentId: savedFile.parentId,
        path: savedFile.path
      });

      return {
        success: true,
        file: savedFile,
        message: '上传完成并记录已保存'
      };

    } catch (error) {
      this.logger.error(`❌ 完成上传失败: ${error.message}`);
      return {
        success: false,
        message: `完成上传失败: ${error.message}`
      };
    }
  }

  /**
   * ✅【新增】生成文件夹内上传URL(专用方法)
   */
  async generateFolderUploadUrl(
    userId: string,
    username: string,
    folderId: string,
    filename: string,
    contentType: string = 'application/octet-stream',
    expiresIn: number = 3600
  ): Promise<any> {
    this.logger.log(`📁 生成文件夹内上传URL:`, {
      userId,
      username,
      folderId,
      filename
    });

    try {
      // 构建上传选项
      const options: PresignedUploadOptionsDto = {
        filename,
        contentType,
        expiresIn,
        parentId: folderId, // ✅ 指定父文件夹ID
      };

      // 调用主方法生成URL
      const result = await this.generatePresignedUrl(userId, username, options);

      if (!result.success) {
        throw new Error('生成文件夹内上传URL失败');
      }

      return {
        success: true,
        data: {
          ...result.data,
          folderId,
          note: '文件夹内上传URL',
          pathInfo: {
            folderId,
            fileKey: result.data.key,
            expectedPath: `uploads/${userId}/folders/${folderId}/**`
          }
        }
      };

    } catch (error) {
      this.logger.error(`❌ 生成文件夹内上传URL失败: ${error.message}`);
      return {
        success: false,
        error: error.message,
        message: '无法在指定文件夹内上传文件'
      };
    }
  }

  /**
   * ✅【新增】批量生成文件夹内上传URL
   */
  async batchGenerateFolderUploadUrls(
    userId: string,
    username: string,
    folderId: string,
    files: Array<{
      filename: string;
      contentType?: string;
    }>,
    expiresIn: number = 3600
  ): Promise<{
    success: boolean;
    count: number;
    folderId: string;
    files: Array<{
      filename: string;
      url: string;
      key: string;
      expiresAt: Date;
      contentType?: string;
    }>;
  }> {
    this.logger.log(`📦 批量生成文件夹内上传URL:`, {
      userId,
      folderId,
      fileCount: files.length
    });

    const results = [];

    for (const file of files) {
      try {
        const result = await this.generateFolderUploadUrl(
          userId,
          username,
          folderId,
          file.filename,
          file.contentType,
          expiresIn
        );

        if (result.success) {
          results.push({
            filename: file.filename,
            url: result.data.url,
            key: result.data.key,
            expiresAt: new Date(Date.now() + expiresIn * 1000),
            contentType: file.contentType,
          });
        }
      } catch (error) {
        this.logger.error(`生成文件 ${file.filename} 的URL失败: ${error.message}`);
      }
    }

    return {
      success: results.length > 0,
      count: results.length,
      folderId,
      files: results
    };
  }

  /**
   * ✅【新增】获取文件夹内已上传文件
   */
  async getFolderUploads(
    userId: string,
    folderId: string
  ): Promise<{
    success: boolean;
    count: number;
    folderId: string;
    files: Array<{
      fileKey: string;
      filename: string;
      uploadedAt: Date;
      size: number;
      status: string;
    }>;
  }> {
    try {
      this.logger.log(`📂 获取文件夹内上传文件:`, { userId, folderId });

      // 从Redis获取该文件夹的上传记录
      const pattern = `presigned:upload:*`;
      const keys = await this.redisService.keys(pattern);

      const folderFiles = [];

      for (const key of keys) {
        const data = await this.redisService.get<RedisPresignedData>(key);
        
        if (data && data.userId === userId && data.parentId === folderId) {
          // 检查文件是否已上传
          const exists = await this.minioService.fileExists(data.fileKey);
          
          folderFiles.push({
            fileKey: data.fileKey,
            filename: data.filename,
            uploadedAt: new Date(data.generatedAt),
            size: 0, // 需要从MinIO获取实际大小
            status: exists ? 'uploaded' : 'pending',
            contentType: data.contentType,
          });
        }
      }

      return {
        success: true,
        count: folderFiles.length,
        folderId,
        files: folderFiles
      };

    } catch (error) {
      this.logger.error(`获取文件夹内上传失败: ${error.message}`);
      return {
        success: false,
        count: 0,
        folderId,
        files: []
      };
    }
  }

  /**
   * ✅【修改】生成上传URL(支持parentId参数)
   */
  async generateUploadUrl(
    userId: string,
    options: {
      filename: string;
      contentType?: string;
      expiresIn?: number;
      bucket?: string;
      prefix?: string;
      parentId?: string; // ✅ 新增:父文件夹ID
    },
    requestHost?: string
  ): Promise<{
    success: boolean;
    data: {
      url: string;
      originalUrl?: string;
      key: string;
      expiresAt: Date;
      filename: string;
      contentType?: string;
      expiresIn: number;
      bucket: string;
      parentId?: string; // ✅ 返回父文件夹ID
      publicUrl?: string;
    };
    timestamp: string;
  }> {
    this.logger.log(`📤 生成上传URL:`, {
      userId,
      filename: options.filename,
      parentId: options.parentId
    });

    const result = await this.generatePresignedUrl(userId, userId, {
      filename: options.filename,
      contentType: options.contentType,
      expiresIn: options.expiresIn,
      bucket: options.bucket,
      prefix: options.prefix,
      parentId: options.parentId, // ✅ 传递父文件夹ID
    }, requestHost);

    return {
      success: result.success,
      data: {
        url: result.data.url,
        originalUrl: result.data.originalUrl,
        key: result.data.key,
        expiresAt: new Date(Date.now() + (result.data.expiresIn || 3600) * 1000),
        filename: result.data.filename,
        contentType: result.data.contentType,
        expiresIn: result.data.expiresIn,
        bucket: result.data.bucket,
        parentId: result.data.parentId, // ✅ 返回父文件夹ID
        publicUrl: result.data.publicUrl,
      },
      timestamp: result.timestamp || new Date().toISOString()
    };
  }

  /**
   * ✅【新增】验证文件夹上传权限
   */
  async validateFolderUpload(
    userId: string,
    folderId: string
  ): Promise<{
    success: boolean;
    canUpload: boolean;
    folderInfo?: any;
    message: string;
  }> {
    try {
      // 检查文件夹是否存在(通过查询数据库)
      // 这里需要先获取File实体,查询文件夹信息
      const folder = await this.fileRepository.findOne({
        where: {
          id: folderId,
          userId,
          isFolder: true,
        },
      });

      if (!folder) {
        return {
          success: false,
          canUpload: false,
          message: '文件夹不存在或无权限'
        };
      }

      // 检查用户是否有上传权限
      // 这里可以添加更多的权限检查逻辑
      const canUpload = folder.userId === userId;

      return {
        success: true,
        canUpload,
        folderInfo: {
          id: folder.id,
          name: folder.filename,
          path: folder.path,
          createdAt: folder.createdAt,
        },
        message: canUpload ? '可以上传到文件夹' : '无上传权限'
      };

    } catch (error) {
      this.logger.error(`验证文件夹上传权限失败: ${error.message}`);
      return {
        success: false,
        canUpload: false,
        message: '验证失败'
      };
    }
  }

  /**
   * ✅【新增】文件夹上传统计
   */
  async getFolderUploadStats(
    userId: string,
    folderId: string
  ): Promise<{
    success: boolean;
    folderId: string;
    stats: {
      totalFiles: number;
      totalSize: number;
      lastUpload: Date | null;
      uploadsToday: number;
    };
  }> {
    try {
      // 查询数据库获取文件夹内文件统计
      const files = await this.fileRepository.find({
        where: {
          userId,
          parentId: folderId,
          isFolder: false,
        },
        order: {
          createdAt: 'DESC',
        },
      });

      const totalFiles = files.length;
      const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
      const lastUpload = files.length > 0 ? files[0].createdAt : null;

      // 计算今日上传数
      const today = new Date();
      today.setHours(0, 0, 0, 0);
      const uploadsToday = files.filter(file => 
        file.createdAt && new Date(file.createdAt) >= today
      ).length;

      return {
        success: true,
        folderId,
        stats: {
          totalFiles,
          totalSize,
          lastUpload,
          uploadsToday,
        },
      };

    } catch (error) {
      this.logger.error(`获取文件夹上传统计失败: ${error.message}`);
      return {
        success: false,
        folderId,
        stats: {
          totalFiles: 0,
          totalSize: 0,
          lastUpload: null,
          uploadsToday: 0,
        },
      };
    }
  }

  /**
   * 验证文件上传是否完成
   */
  async verifyFileUpload(fileKey: string): Promise<{
    success: boolean;
    exists: boolean;
    details?: any;
  }> {
    try {
      this.logger.log(`🔍 验证文件上传: ${fileKey}`);

      const redisData = await this.redisService.get<RedisPresignedData>(`presigned:upload:${fileKey}`);
      const exists = await this.minioService.fileExists(fileKey);

      if (exists) {
        const fileDetails = await this.minioService.getFileDetails(fileKey);

        return {
          success: true,
          exists: true,
          details: {
            fileInfo: fileDetails.info,
            publicUrl: fileDetails.publicUrl,
            signedUrl: fileDetails.signedUrl,
            redisData,
            folderId: redisData?.parentId, // ✅ 返回文件夹ID
            bucket: this.bucketName,
          }
        };
      }

      return {
        success: true,
        exists: false,
        details: {
          message: '文件不存在于存储桶中',
          redisData,
          folderId: redisData?.parentId,
          suggestion: redisData ? '预签名记录存在,但文件未上传' : '无预签名记录'
        }
      };
    } catch (error) {
      this.logger.error(`验证文件上传失败: ${error.message}`);
      return {
        success: false,
        exists: false,
        details: { error: error.message }
      };
    }
  }

  /**
   * 获取未完成的上传任务
   */
  async getUncompletedUploads(userId: string): Promise<{
    success: boolean;
    count: number;
    uploads: Array<{
      fileKey: string;
      createdAt: number;
      expiresAt: number;
      filename: string;
      status: string;
      timeLeft: number;
      parentId?: string; // ✅ 新增:返回父文件夹ID
    }>;
    summary: {
      total: number;
      expired: number;
      active: number;
      withParentId: number; // ✅ 新增:有父文件夹的数量
    };
  }> {
    try {
      this.logger.log(`📋 获取用户 ${userId} 的未完成上传`);

      const pattern = `presigned:upload:*`;
      const keys = await this.redisService.keys(pattern);

      const uploads = [];
      let expiredCount = 0;
      let activeCount = 0;
      let withParentIdCount = 0;

      for (const key of keys) {
        const data = await this.redisService.get<RedisPresignedData>(key);

        if (data && data.userId === userId) {
          const now = Date.now();
          const isExpired = data.expiresAt < now;

          if (isExpired) {
            expiredCount++;
          } else {
            activeCount++;
          }

          if (data.parentId) {
            withParentIdCount++;
          }

          uploads.push({
            fileKey: data.fileKey,
            createdAt: data.generatedAt || 0,
            expiresAt: data.expiresAt || 0,
            filename: data.filename || '未知文件',
            status: isExpired ? 'expired' : 'active',
            timeLeft: isExpired ? 0 : Math.max(0, Math.floor((data.expiresAt - now) / 1000)),
            parentId: data.parentId, // ✅ 返回父文件夹ID
            contentType: data.contentType,
          });
        }
      }

      uploads.sort((a, b) => b.createdAt - a.createdAt);

      return {
        success: true,
        count: uploads.length,
        uploads,
        summary: {
          total: uploads.length,
          expired: expiredCount,
          active: activeCount,
          withParentId: withParentIdCount
        }
      };
    } catch (error) {
      this.logger.error(`获取未完成上传失败: ${error.message}`);
      return {
        success: false,
        count: 0,
        uploads: [],
        summary: {
          total: 0,
          expired: 0,
          active: 0,
          withParentId: 0
        }
      };
    }
  }

  /**
   * 清理过期的上传记录
   */
  async cleanupExpiredUploads(userId?: string): Promise<{
    success: boolean;
    cleaned: number;
    failed: number;
    details: Array<{
      fileKey: string;
      action: string;
      success: boolean;
      message?: string;
      parentId?: string;
    }>;
    message: string;
  }> {
    try {
      this.logger.log(`🧹 清理过期上传记录 ${userId ? `(用户: ${userId})` : ''}`);

      const pattern = `presigned:upload:*`;
      const keys = await this.redisService.keys(pattern);

      let cleaned = 0;
      let failed = 0;
      const details = [];

      const now = Date.now();

      for (const key of keys) {
        const data = await this.redisService.get<RedisPresignedData>(key);

        if (data) {
          if (userId && data.userId !== userId) {
            continue;
          }

          const fileKey = data.fileKey;

          try {
            if (data.expiresAt < now) {
              const fileExists = await this.minioService.fileExists(fileKey);

              if (!fileExists) {
                await this.redisService.del(key);
                details.push({
                  fileKey,
                  action: 'delete_redis_record',
                  success: true,
                  message: '删除过期预签名记录',
                  parentId: data.parentId
                });
                cleaned++;
              } else {
                details.push({
                  fileKey,
                  action: 'keep_with_expired_status',
                  success: true,
                  message: '文件已上传,保留记录但标记为过期',
                  parentId: data.parentId
                });
              }
            } else {
              details.push({
                fileKey,
                action: 'skip_not_expired',
                success: true,
                message: `记录未过期,剩余时间: ${Math.floor((data.expiresAt - now) / 1000)}秒`,
                parentId: data.parentId
              });
            }
          } catch (error) {
            failed++;
            details.push({
              fileKey,
              action: 'cleanup',
              success: false,
              message: error.message,
              parentId: data.parentId
            });
          }
        }
      }

      const message = `清理完成:成功清理 ${cleaned} 条记录,失败 ${failed} 条`;

      this.logger.log(message);

      return {
        success: true,
        cleaned,
        failed,
        details,
        message
      };
    } catch (error) {
      this.logger.error(`清理过期上传失败: ${error.message}`);
      return {
        success: false,
        cleaned: 0,
        failed: 0,
        details: [],
        message: error.message,
      };
    }
  }

  /**
   * 批量生成上传URL
   */
  async batchGenerateUploadUrls(
    userId: string,
    username: string,
    files: Array<{
      filename: string;
      contentType?: string;
      customPath?: string;
    }>,
    options?: {
      expiresIn?: number;
      bucket?: string;
      parentId?: string; // ✅ 新增:支持父文件夹ID
    },
    requestHost?: string
  ): Promise<{
    success: boolean;
    count: number;
    files: Array<{
      filename: string;
      url: string;
      originalUrl?: string;
      key: string;
      expiresAt: Date;
      contentType?: string;
      parentId?: string; // ✅ 返回父文件夹ID
    }>;
    timestamp: string;
  }> {
    this.logger.log(`📦 批量生成上传URL:`, {
      userId,
      parentId: options?.parentId,
      fileCount: files.length
    });

    const results = [];
    const expiresIn = options?.expiresIn || 3600;
    const bucket = options?.bucket || this.bucketName;

    for (const file of files) {
      try {
        const result = await this.generatePresignedUrl(
          userId,
          username,
          {
            filename: file.filename,
            contentType: file.contentType,
            expiresIn,
            bucket,
            prefix: file.customPath,
            parentId: options?.parentId, // ✅ 传递父文件夹ID
          },
          requestHost
        );

        if (result.success) {
          results.push({
            filename: file.filename,
            url: result.data.url,
            originalUrl: result.data.originalUrl,
            key: result.data.key,
            expiresAt: new Date(Date.now() + expiresIn * 1000),
            contentType: file.contentType,
            parentId: options?.parentId, // ✅ 返回父文件夹ID
          });
        }
      } catch (error) {
        this.logger.error(`生成文件 ${file.filename} 的URL失败: ${error.message}`);
      }
    }

    return {
      success: results.length > 0,
      count: results.length,
      files: results,
      timestamp: new Date().toISOString()
    };
  }

  /**
   * 获取上传统计信息
   */
  async getUploadStats(userId?: string): Promise<{
    success: boolean;
    stats: {
      totalUploads: number;
      activeUploads: number;
      expiredUploads: number;
      totalFilesInBucket: number;
      bucketSize: number;
      userTotalUploads?: number;
      folderUploads?: number; // ✅ 新增:文件夹上传统计
    };
    timestamp: string;
  }> {
    try {
      const pattern = `presigned:upload:*`;
      const keys = await this.redisService.keys(pattern);

      let totalUploads = 0;
      let activeUploads = 0;
      let expiredUploads = 0;
      let userTotalUploads = 0;
      let folderUploads = 0;

      const now = Date.now();

      for (const key of keys) {
        const data = await this.redisService.get<RedisPresignedData>(key);

        if (data) {
          totalUploads++;

          if (userId && data.userId === userId) {
            userTotalUploads++;
          }

          if (data.parentId) {
            folderUploads++;
          }

          if (data.expiresAt < now) {
            expiredUploads++;
          } else {
            activeUploads++;
          }
        }
      }

      let totalFilesInBucket = 0;
      let bucketSize = 0;

      try {
        const bucketUsage = await this.minioService.getBucketUsage();
        totalFilesInBucket = bucketUsage.totalObjects;
        bucketSize = bucketUsage.totalSize;
      } catch (error) {
        this.logger.warn(`获取存储桶信息失败: ${error.message}`);
      }

      return {
        success: true,
        stats: {
          totalUploads,
          activeUploads,
          expiredUploads,
          totalFilesInBucket,
          bucketSize,
          ...(userId && { userTotalUploads }),
          folderUploads // ✅ 新增
        },
        timestamp: new Date().toISOString()
      };
    } catch (error) {
      this.logger.error(`获取上传统计失败: ${error.message}`);
      return {
        success: false,
        stats: {
          totalUploads: 0,
          activeUploads: 0,
          expiredUploads: 0,
          totalFilesInBucket: 0,
          bucketSize: 0,
          ...(userId && { userTotalUploads: 0 }),
          folderUploads: 0
        },
        timestamp: new Date().toISOString()
      };
    }
  }

  /**
   * 测试预签名URL
   */
  async testPresignedUpload(options: PresignedUploadOptionsDto): Promise<{
    success: boolean;
    url: string;
    originalUrl?: string;
    curlCommand: string;
    testResult?: string;
  }> {
    try {
      const bucket = this.bucketName;
      const key = this.generateFolderAwareFileKey(
        'test-user', 
        options.filename, 
        options.parentId, // ✅ 测试文件夹上传
        'test'
      );
      const contentType = options.contentType || 'text/plain';

      const url = await this.generateAwsPresignedUrl(
        bucket,
        key,
        contentType,
        600
      );

      const curlCommand = `curl -X PUT "${url}" -H "Content-Type: ${contentType}" --data-binary "Test content from ${new Date().toISOString()}"`;

      return {
        success: true,
        url: url,
        originalUrl: url,
        curlCommand,
      };
    } catch (error) {
      return {
        success: false,
        url: '',
        curlCommand: '',
        testResult: error.message,
      };
    }
  }

  /**
   * 智能生成预签名URL
   */
  async generateSmartPresignedUrl(
    userId: string,
    username: string,
    options: PresignedUploadOptionsDto,
    clientInfo?: {
      hostname?: string;
      origin?: string;
    }
  ): Promise<any> {
    this.logger.log(`🤖 智能生成预签名URL,客户端: ${JSON.stringify(clientInfo)}`, {
      parentId: options.parentId
    });
    return this.generatePresignedUrl(userId, username, options, clientInfo?.hostname);
  }

  /**
   * 获取URL生成选项
   */
  getUrlGenerationOptions(): {
    endpoint: string;
    bucket: string;
    architecture: string;
    pathSafety: boolean;
    folderSupport: boolean; // ✅ 新增:文件夹支持
  } {
    return {
      endpoint: this.minioEndpoint,
      bucket: this.bucketName,
      architecture: '前端通过Nginx代理(9002)访问MinIO集群',
      pathSafety: true,
      folderSupport: true, // ✅ 新增
    };
  }

  /**
   * 获取用户的预签名URL历史
   */
  async getUserPresignedHistory(
    userId: string,
    limit: number = 50,
    offset: number = 0
  ): Promise<{
    success: boolean;
    total: number;
    history: Array<{
      fileKey: string;
      filename: string;
      generatedAt: Date;
      expiresAt: Date;
      status: string;
      uploaded: boolean;
      parentId?: string; // ✅ 新增:父文件夹ID
    }>;
  }> {
    try {
      const pattern = `presigned:upload:*`;
      const keys = await this.redisService.keys(pattern);

      const history = [];

      for (const key of keys) {
        const data = await this.redisService.get<RedisPresignedData>(key);
        if (data && data.userId === userId) {
          const uploaded = await this.minioService.fileExists(data.fileKey);

          history.push({
            fileKey: data.fileKey,
            filename: data.filename,
            generatedAt: new Date(data.generatedAt),
            expiresAt: new Date(data.expiresAt),
            status: data.expiresAt < Date.now() ? 'expired' : 'active',
            uploaded,
            parentId: data.parentId, // ✅ 新增
          });
        }
      }

      history.sort((a, b) => b.generatedAt.getTime() - a.generatedAt.getTime());

      return {
        success: true,
        total: history.length,
        history: history.slice(offset, offset + limit),
      };
    } catch (error) {
      this.logger.error(`获取历史失败: ${error.message}`);
      return {
        success: false,
        total: 0,
        history: [],
      };
    }
  }

  /**
   * 获取用户上传状态
   */
  async getUserUploadStates(userId: string): Promise<{
    success: boolean;
    data: {
      total: number;
      completed: number;
      pending: number;
      failed: number;
      uploads: Array<{
        fileKey: string;
        filename: string;
        status: string;
        createdAt: Date;
        updatedAt: Date;
        size?: number;
        parentId?: string; // ✅ 新增:父文件夹ID
      }>;
    };
  }> {
    try {
      const pattern = `upload:status:${userId}:*`;
      const keys = await this.redisService.keys(pattern);

      const uploads = [];
      let completed = 0;
      let pending = 0;
      let failed = 0;

      for (const key of keys) {
        const statusData = await this.redisService.get<{
          status: string;
          updatedAt: number;
          fileKey: string;
          userId: string;
          parentId?: string; // ✅ 新增
        }>(key);

        if (statusData) {
          uploads.push({
            fileKey: statusData.fileKey,
            filename: statusData.fileKey.split('/').pop() || 'unknown',
            status: statusData.status,
            createdAt: new Date(),
            updatedAt: new Date(statusData.updatedAt),
            parentId: statusData.parentId, // ✅ 新增
          });

          switch (statusData.status) {
            case 'completed': completed++; break;
            case 'pending': pending++; break;
            case 'failed': failed++; break;
          }
        }
      }

      return {
        success: true,
        data: {
          total: uploads.length,
          completed,
          pending,
          failed,
          uploads: uploads.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
        }
      };

    } catch (error) {
      this.logger.error(`获取用户上传状态失败: ${error.message}`);
      return {
        success: false,
        data: {
          total: 0,
          completed: 0,
          pending: 0,
          failed: 0,
          uploads: []
        }
      };
    }
  }

  /**
   * 跟踪上传状态
   */
  async trackUploadStatus(
    userId: string,
    fileKey: string,
    status: string,
    details?: any
  ): Promise<{
    success: boolean;
    message: string;
  }> {
    const statusKey = `upload:status:${userId}:${fileKey}`;

    try {
      await this.redisService.set(statusKey, {
        status,
        updatedAt: Date.now(),
        fileKey,
        userId,
        details
      }, 86400);

      this.logger.log(`📊 上传状态跟踪: ${userId} -> ${fileKey} -> ${status}`);

      return {
        success: true,
        message: '状态跟踪成功'
      };
    } catch (error) {
      this.logger.error(`状态跟踪失败: ${error.message}`);
      return {
        success: false,
        message: `状态跟踪失败: ${error.message}`
      };
    }
  }

  /**
   * 检查预签名URL是否有效
   */
  async validatePresignedUrl(url: string): Promise<{
    valid: boolean;
    expiresAt?: Date;
    fileKey?: string;
    error?: string;
  }> {
    try {
      const urlObj = new URL(url);
      const params = new URLSearchParams(urlObj.search);

      const signature = params.get('X-Amz-Signature');
      const expires = params.get('X-Amz-Expires');
      const dateStr = params.get('X-Amz-Date');

      if (!signature || !expires || !dateStr) {
        return { valid: false, error: '缺少签名参数' };
      }

      const date = new Date(
        parseInt(dateStr.substring(0, 4)),
        parseInt(dateStr.substring(4, 6)) - 1,
        parseInt(dateStr.substring(6, 8)),
        parseInt(dateStr.substring(9, 11)),
        parseInt(dateStr.substring(11, 13)),
        parseInt(dateStr.substring(13, 15))
      );

      const expiresAt = new Date(date.getTime() + parseInt(expires) * 1000);
      const isValid = expiresAt > new Date();

      const pathParts = urlObj.pathname.split('/');
      const fileKey = pathParts.slice(2).join('/');

      return {
        valid: isValid,
        expiresAt,
        fileKey,
      };
    } catch (error) {
      return { valid: false, error: error.message };
    }
  }

  /**
   * 获取配置信息(用于调试)
   */
  getConfigInfo() {
    return {
      architecture: {
        frontend: '192.168.39.152:5173',
        backend: '192.168.39.152:3000',
        nginxProxy: 'www.fox360.cn:9002',
        minioCluster: 'www.fox360.cn:9000/9001 (HTTPS)',
      },
      urls: {
        endpoint: this.minioEndpoint,
        bucket: this.bucketName,
      },
      features: {
        pathSafety: '已修复双斜杠问题',
        folderSupport: '支持文件夹内上传',
        uploadPathPattern: 'uploads/{userId}/folders/{folderId}/YYYY/MM/DD/timestamp-random-filename'
      },
      note: '前端通过Nginx代理(9002)上传文件到MinIO集群'
    };
  }

  /**
   * ✅【新增】测试文件夹上传功能
   */
  async testFolderUpload(
    userId: string,
    folderId: string
  ): Promise<{
    success: boolean;
    testFiles: Array<{
      filename: string;
      url: string;
      fileKey: string;
      folderId: string;
      status: string;
    }>;
    summary: {
      total: number;
      generated: number;
      expectedPath: string;
    };
  }> {
    const testFiles = [
      { filename: 'test-file-1.txt', contentType: 'text/plain' },
      { filename: 'test-file-2.jpg', contentType: 'image/jpeg' },
      { filename: 'test-file-3.pdf', contentType: 'application/pdf' },
    ];

    const results = [];

    for (const testFile of testFiles) {
      try {
        const result = await this.generateFolderUploadUrl(
          userId,
          'test-user',
          folderId,
          testFile.filename,
          testFile.contentType,
          600
        );

        if (result.success) {
          results.push({
            filename: testFile.filename,
            url: result.data.url.substring(0, 100) + '...',
            fileKey: result.data.key,
            folderId,
            status: 'generated',
          });
        } else {
          results.push({
            filename: testFile.filename,
            url: '',
            fileKey: '',
            folderId,
            status: 'failed',
          });
        }
      } catch (error) {
        results.push({
          filename: testFile.filename,
          url: '',
          fileKey: '',
          folderId,
          status: 'error',
        });
      }
    }

    return {
      success: results.some(r => r.status === 'generated'),
      testFiles: results,
      summary: {
        total: testFiles.length,
        generated: results.filter(r => r.status === 'generated').length,
        expectedPath: `uploads/${userId}/folders/${folderId}/**`
      }
    };
  }
}

JTBC