Jelajahi Sumber

✨ feat(files): add file upload and management system

- add uuid package dependency for generating unique file names
- create File entity with database schema and validation schemas
- implement FileService with methods for file CRUD operations
- develop MinioService for object storage operations including:
  - bucket management and policy configuration
  - single and multipart upload support
  - file access URL generation
  - file existence check and deletion

✨ feat(file-service): implement file upload workflows

- add createFile method for single file upload with pre-signed URL
- implement deleteFile method to remove files from both DB and storage
- add getFileUrl method to generate access URLs for files
- develop multipart upload support with:
  - createMultipartUploadPolicy for initiating multipart uploads
  - completeMultipartUpload for finalizing chunked uploads

✨ feat(minio): add MinIO integration for object storage

- configure MinIO client with environment variables
- implement bucket existence check and automatic creation
- add public read policy configuration for buckets
- develop methods for generating pre-signed upload URLs
- support both single and multipart upload strategies
- implement file metadata retrieval and size tracking
yourname 4 bulan lalu
induk
melakukan
a9e0e48946

+ 2 - 1
package.json

@@ -41,7 +41,8 @@
     "react-router-dom": "^7.6.1",
     "react-toastify": "^11.0.5",
     "reflect-metadata": "^0.2.2",
-    "typeorm": "^0.3.24"
+    "typeorm": "^0.3.24",
+    "uuid": "^11.1.0"
   },
   "devDependencies": {
     "@types/debug": "^4.1.12",

+ 3 - 0
pnpm-lock.yaml

@@ -110,6 +110,9 @@ importers:
       typeorm:
         specifier: ^0.3.24
         version: 0.3.24(babel-plugin-macros@3.1.0)(ioredis@5.6.1)(mysql2@3.14.1)(reflect-metadata@0.2.2)
+      uuid:
+        specifier: ^11.1.0
+        version: 11.1.0
     devDependencies:
       '@types/debug':
         specifier: ^4.1.12

+ 158 - 0
src/server/modules/files/file.entity.ts

@@ -0,0 +1,158 @@
+import { Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+import { UserEntity, UserSchema } from '@/server/modules/users/user.entity';
+
+@Entity('file')
+export class File {
+  @PrimaryGeneratedColumn({ name: 'id', type: 'int', unsigned: true })
+  id!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255 })
+  name!: string;
+
+  @Column({ name: 'type', type: 'varchar', length: 50, nullable: true })
+  type?: string;
+
+  @Column({ name: 'size', type: 'int', unsigned: true, nullable: true })
+  size?: number;
+
+  @Column({ name: 'path', type: 'varchar', length: 512 })
+  path!: string;
+
+  @Column({ name: 'description', type: 'text', nullable: true })
+  description?: string;
+
+  @Column({ name: 'upload_user_id', type: 'int', unsigned: true })
+  uploadUserId!: number;
+
+  @ManyToOne(() => UserEntity)
+  @JoinColumn({ name: 'upload_user_id', referencedColumnName: 'id' })
+  uploadUser!: UserEntity;
+
+  @Column({ name: 'upload_time', type: 'datetime' })
+  uploadTime!: Date;
+
+  @Column({ name: 'last_updated', type: 'datetime', nullable: true })
+  lastUpdated?: Date;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ 
+    name: 'updated_at', 
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP', 
+    onUpdate: 'CURRENT_TIMESTAMP' 
+  })
+  updatedAt!: Date;
+}
+
+export const FileSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '文件ID',
+    example: 1
+  }),
+  name: z.string().max(255).openapi({
+    description: '文件名称',
+    example: '项目计划书.pdf'
+  }),
+  type: z.string().max(50).nullable().openapi({
+    description: '文件类型',
+    example: 'application/pdf'
+  }),
+  size: z.number().int().positive().nullable().openapi({
+    description: '文件大小,单位字节',
+    example: 102400
+  }),
+  path: z.string().max(512).openapi({
+    description: '文件存储路径',
+    example: '/uploads/documents/2023/project-plan.pdf'
+  }),
+  description: z.string().nullable().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书'
+  }),
+  uploadUserId: z.number().int().positive().openapi({
+    description: '上传用户ID',
+    example: 1
+  }),
+  uploadUser: UserSchema,
+  uploadTime: z.date().openapi({
+    description: '上传时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  lastUpdated: z.date().nullable().openapi({
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z'
+  }),
+  createdAt: z.date().openapi({
+    description: '创建时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  updatedAt: z.date().openapi({
+    description: '更新时间',
+    example: '2023-01-16T14:20:00Z'
+  })
+});
+
+export const CreateFileDto = z.object({
+  name: z.string().max(255).openapi({
+    description: '文件名称',
+    example: '项目计划书.pdf'
+  }),
+  type: z.string().max(50).nullable().optional().openapi({
+    description: '文件类型',
+    example: 'application/pdf'
+  }),
+  size: z.coerce.number().int().positive().nullable().optional().openapi({
+    description: '文件大小,单位字节',
+    example: 102400
+  }),
+  path: z.string().max(512).openapi({
+    description: '文件存储路径',
+    example: '/uploads/documents/2023/project-plan.pdf'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书'
+  }),
+  lastUpdated: z.coerce.date().nullable().optional().openapi({
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z'
+  })
+});
+
+export const UpdateFileDto = z.object({
+  name: z.string().max(255).optional().openapi({ 
+    description: '文件名称',
+    example: '项目计划书_v2.pdf' 
+  }),
+  type: z.string().max(50).nullable().optional().openapi({ 
+    description: '文件类型',
+    example: 'application/pdf' 
+  }),
+  size: z.coerce.number().int().positive().nullable().optional().openapi({ 
+    description: '文件大小,单位字节',
+    example: 153600 
+  }),
+  path: z.string().max(512).optional().openapi({ 
+    description: '文件存储路径',
+    example: '/uploads/documents/2023/project-plan_v2.pdf' 
+  }),
+  description: z.string().nullable().optional().openapi({ 
+    description: '文件描述',
+    example: '2023年度项目计划书(修订版)' 
+  }),
+  uploadUserId: z.number().int().positive().optional().openapi({
+    description: '上传用户ID',
+    example: 1
+  }),
+  uploadTime: z.coerce.date().optional().openapi({ 
+    description: '上传时间',
+    example: '2023-01-15T10:30:00Z' 
+  }),
+  lastUpdated: z.coerce.date().nullable().optional().openapi({ 
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z' 
+  })
+});

+ 185 - 0
src/server/modules/files/file.service.ts

@@ -0,0 +1,185 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { File } from './file.entity';
+import { MinioService } from './minio.service';
+// import { AppError } from '@/server/utils/errorHandler';
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '@/server/utils/logger';
+
+export class FileService extends GenericCrudService<File> {
+  private readonly minioService: MinioService;
+
+  constructor(dataSource: DataSource) {
+    super(dataSource, File);
+    this.minioService = new MinioService();
+  }
+
+  /**
+   * 创建文件记录并生成预签名上传URL
+   */
+  async createFile(data: Partial<File>) {
+    try {
+      // 生成唯一文件存储路径
+      const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
+      
+      // 生成MinIO上传策略
+      const uploadPolicy = await this.minioService.generateUploadPolicy(fileKey);
+      
+      // 准备文件记录数据
+      const fileData = {
+        ...data,
+        path: fileKey,
+        uploadTime: new Date(),
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+      
+      // 保存文件记录到数据库
+      const savedFile = await this.create(fileData as File);
+      
+      // 返回文件记录和上传策略
+      return {
+        file: savedFile,
+        uploadPolicy
+      };
+    } catch (error) {
+      logger.error('Failed to create file:', error);
+      throw new Error('文件创建失败');
+    }
+  }
+
+  /**
+   * 删除文件记录及对应的MinIO文件
+   */
+  async deleteFile(id: number) {
+    try {
+      // 获取文件记录
+      const file = await this.getById(id);
+      if (!file) {
+        throw new Error('文件不存在');
+      }
+      
+      // 验证文件是否存在于MinIO
+      const fileExists = await this.minioService.objectExists(this.minioService.bucketName, file.path);
+      if (!fileExists) {
+        logger.error(`File not found in MinIO: ${this.minioService.bucketName}/${file.path}`);
+        // 仍然继续删除数据库记录,但记录警告日志
+      } else {
+        // 从MinIO删除文件
+        await this.minioService.deleteObject(this.minioService.bucketName, file.path);
+      }
+      
+      // 从数据库删除记录
+      await this.delete(id);
+      
+      return true;
+    } catch (error) {
+      logger.error('Failed to delete file:', error);
+      throw new Error('文件删除失败');
+    }
+  }
+
+  /**
+   * 获取文件访问URL
+   */
+  async getFileUrl(id: number) {
+    const file = await this.getById(id);
+    if (!file) {
+      throw new Error('文件不存在');
+    }
+    
+    return this.minioService.getFileUrl(this.minioService.bucketName, file.path);
+  }
+
+  /**
+   * 创建多部分上传策略
+   */
+  async createMultipartUploadPolicy(data: Partial<File>, partCount: number) {
+    try {
+      // 生成唯一文件存储路径
+      const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
+      
+      // 初始化多部分上传
+      const uploadId = await this.minioService.createMultipartUpload(
+        this.minioService.bucketName,
+        fileKey
+      );
+      
+      // 生成各部分上传URL
+      const uploadUrls = await this.minioService.generateMultipartUploadUrls(
+        this.minioService.bucketName,
+        fileKey,
+        uploadId,
+        partCount
+      );
+      
+      // 准备文件记录数据
+      const fileData = {
+        ...data,
+        path: fileKey,
+        uploadTime: new Date(),
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+      
+      // 保存文件记录到数据库
+      const savedFile = await this.create(fileData as File);
+      
+      // 返回文件记录和上传策略
+      return {
+        file: savedFile,
+        uploadId,
+        uploadUrls,
+        bucket: this.minioService.bucketName,
+        key: fileKey
+      };
+    } catch (error) {
+      logger.error('Failed to create multipart upload policy:', error);
+      throw new Error('创建多部分上传策略失败');
+    }
+  }
+
+  /**
+   * 完成分片上传
+   */
+  async completeMultipartUpload(data: {
+    uploadId: string;
+    bucket: string;
+    key: string;
+    parts: Array<{ PartNumber: number; ETag: string }>;
+  }) {
+    try {
+      // 完成MinIO分片上传
+      const result = await this.minioService.completeMultipartUpload(
+        data.bucket,
+        data.key,
+        data.uploadId,
+        data.parts.map(part => ({ PartNumber: part.PartNumber, ETag: part.ETag }))
+      );
+      
+      // 查找文件记录并更新
+      const file = await this.repository.findOneBy({ path: data.key });
+      if (!file) {
+        throw new Error('文件记录不存在');
+      }
+      
+      // 更新文件大小等信息
+      file.size = result.size;
+      file.updatedAt = new Date();
+      await this.repository.save(file);
+      
+      // 生成文件访问URL
+      const url = this.minioService.getFileUrl(data.bucket, data.key);
+      
+      return {
+        fileId: file.id,
+        url,
+        key: data.key,
+        size: result.size
+      };
+    } catch (error) {
+      logger.error('Failed to complete multipart upload:', error);
+      throw new Error('完成分片上传失败');
+    }
+  }
+}

+ 204 - 0
src/server/modules/files/minio.service.ts

@@ -0,0 +1,204 @@
+import { Client } from 'minio';
+import { logger } from '@/server/utils/logger';
+import * as process from 'node:process';
+
+export class MinioService {
+  private readonly client: Client;
+  public readonly bucketName: string;
+
+  constructor() {
+    this.client = new Client({
+      endPoint: process.env.MINIO_HOST || 'localhost',
+      port: parseInt(process.env.MINIO_PORT || '443'),
+      useSSL: process.env.MINIO_USE_SSL !== 'false',
+      accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
+      secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
+    });
+    this.bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+  }
+
+  // 设置桶策略为"公读私写"
+  async setPublicReadPolicy(bucketName: string = this.bucketName) {
+    const policy = {
+      "Version": "2012-10-17",
+      "Statement": [
+        {
+          "Effect": "Allow",
+          "Principal": {"AWS": "*"},
+          "Action": ["s3:GetObject"],
+          "Resource": [`arn:aws:s3:::${bucketName}/*`]
+        },
+        {
+          "Effect": "Allow",
+          "Principal": {"AWS": "*"},
+          "Action": ["s3:ListBucket"],
+          "Resource": [`arn:aws:s3:::${bucketName}`]
+        }
+      ]
+    };
+
+    try {
+      await this.client.setBucketPolicy(bucketName, JSON.stringify(policy));
+      logger.db(`Bucket policy set to public read for: ${bucketName}`);
+    } catch (error) {
+      logger.error(`Failed to set bucket policy for ${bucketName}:`, error);
+      throw error;
+    }
+  }
+
+  // 确保存储桶存在
+  async ensureBucketExists(bucketName: string = this.bucketName) {
+    try {
+      const exists = await this.client.bucketExists(bucketName);
+      if (!exists) {
+        await this.client.makeBucket(bucketName);
+        await this.setPublicReadPolicy(bucketName);
+        logger.db(`Created new bucket: ${bucketName}`);
+      }
+      return true;
+    } catch (error) {
+      logger.error(`Failed to ensure bucket exists: ${bucketName}`, error);
+      throw error;
+    }
+  }
+
+  // 生成上传策略
+  async generateUploadPolicy(fileKey: string) {
+    await this.ensureBucketExists();
+    
+    const expiresAt = new Date(Date.now() + 3600 * 1000);
+    const policy = this.client.newPostPolicy();
+    policy.setBucket(this.bucketName);
+    
+    policy.setKey(fileKey);
+    policy.setExpires(expiresAt);
+
+    const { postURL, formData } = await this.client.presignedPostPolicy(policy);
+
+    return {
+      'x-amz-algorithm': formData['x-amz-algorithm'],
+      'x-amz-credential': formData['x-amz-credential'],
+      'x-amz-date': formData['x-amz-date'],
+      'x-amz-security-token': formData['x-amz-security-token'] || undefined,
+      policy: formData['policy'],
+      'x-amz-signature': formData['x-amz-signature'],
+      host: postURL,
+      key: fileKey,
+      bucket: this.bucketName,
+    };
+  }
+
+  // 生成文件访问URL
+  getFileUrl(bucketName: string, fileKey: string) {
+    const protocol = process.env.MINIO_USE_SSL ? 'https' : 'http';
+    const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+    return `${protocol}://${process.env.MINIO_ENDPOINT}${port}/${bucketName}/${fileKey}`;
+  }
+
+  // 创建分段上传会话
+  async createMultipartUpload(bucketName: string, objectName: string) {
+    try {
+      const uploadId = await this.client.initiateNewMultipartUpload(bucketName, objectName, {});
+      logger.db(`Created multipart upload for ${objectName} with ID: ${uploadId}`);
+      return uploadId;
+    } catch (error) {
+      logger.error(`Failed to create multipart upload for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 生成分段上传预签名URL
+  async generateMultipartUploadUrls(
+    bucketName: string,
+    objectName: string,
+    uploadId: string,
+    partCount: number,
+    expiresInSeconds = 3600
+  ) {
+    try {
+      const partUrls = [];
+      for (let partNumber = 1; partNumber <= partCount; partNumber++) {
+        const url = await this.client.presignedUrl(
+          'put',
+          bucketName,
+          objectName,
+          expiresInSeconds,
+          {
+            uploadId,
+            partNumber: partNumber.toString()
+          }
+        );
+        partUrls.push(url);
+      }
+      return partUrls;
+    } catch (error) {
+      logger.error(`Failed to generate multipart upload URLs for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 完成分段上传
+  async completeMultipartUpload(
+    bucketName: string,
+    objectName: string,
+    uploadId: string,
+    parts: { ETag: string; PartNumber: number }[]
+  ): Promise<{ size: number }> {
+    try {
+      await this.client.completeMultipartUpload(
+        bucketName,
+        objectName,
+        uploadId,
+        parts.map(p => ({ part: p.PartNumber, etag: p.ETag }))
+      );
+      logger.db(`Completed multipart upload for ${objectName} with ID: ${uploadId}`);
+      
+      // 获取对象信息以获取文件大小
+      const stat = await this.client.statObject(bucketName, objectName);
+      return { size: stat.size };
+    } catch (error) {
+      logger.error(`Failed to complete multipart upload for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 上传文件
+  async createObject(bucketName: string, objectName: string, fileContent: Buffer, contentType: string = 'application/octet-stream') {
+    try {
+      await this.ensureBucketExists(bucketName);
+      await this.client.putObject(bucketName, objectName, fileContent, fileContent.length, {
+        'Content-Type': contentType
+      });
+      logger.db(`Created object: ${bucketName}/${objectName}`);
+      return this.getFileUrl(bucketName, objectName);
+    } catch (error) {
+      logger.error(`Failed to create object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 检查文件是否存在
+  async objectExists(bucketName: string, objectName: string): Promise<boolean> {
+    try {
+      await this.client.statObject(bucketName, objectName);
+      return true;
+    } catch (error) {
+      if ((error as Error).message.includes('not found')) {
+        return false;
+      }
+      logger.error(`Error checking existence of object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 删除文件
+  async deleteObject(bucketName: string, objectName: string) {
+    try {
+      await this.client.removeObject(bucketName, objectName);
+      logger.db(`Deleted object: ${bucketName}/${objectName}`);
+    } catch (error) {
+      logger.error(`Failed to delete object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+}