Jelajahi Sumber

✨ feat(file-module): 新增文件管理模块

- 新增文件实体类,包含文件基础信息和MinIO存储路径
- 实现文件服务类,支持文件上传、下载、删除等操作
- 集成MinIO对象存储服务,支持预签名URL和分片上传
- 提供完整的RESTful API路由,包括文件CRUD操作
- 支持文件上传策略生成和多部分上传功能
- 添加文件Schema验证和类型定义
- 配置模块化导出结构,便于其他模块引用
yourname 4 minggu lalu
induk
melakukan
c6613bf2d5

+ 63 - 0
packages/file-module/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@d8d/file-module",
+  "version": "1.0.0",
+  "type": "module",
+  "description": "D8D File Management Module",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./src/index.ts",
+      "require": "./src/index.ts",
+      "types": "./src/index.ts"
+    },
+    "./entities": {
+      "import": "./src/entities/index.ts",
+      "require": "./src/entities/index.ts",
+      "types": "./src/entities/index.ts"
+    },
+    "./services": {
+      "import": "./src/services/index.ts",
+      "require": "./src/services/index.ts",
+      "types": "./src/services/index.ts"
+    },
+    "./schemas": {
+      "import": "./src/schemas/index.ts",
+      "require": "./src/schemas/index.ts",
+      "types": "./src/schemas/index.ts"
+    },
+    "./routes": {
+      "import": "./src/routes/index.ts",
+      "require": "./src/routes/index.ts",
+      "types": "./src/routes/index.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc",
+    "dev": "tsc --watch",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest",
+    "test:unit": "vitest run tests/unit",
+    "test:integration": "vitest run tests/integration",
+    "test:coverage": "vitest --coverage",
+    "test:typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@d8d/shared-types": "workspace:*",
+    "@d8d/shared-utils": "workspace:*",
+    "@d8d/shared-crud": "workspace:*",
+    "minio": "^8.0.5",
+    "typeorm": "^0.3.20",
+    "uuid": "^11.1.0",
+    "zod": "^4.1.12"
+  },
+  "devDependencies": {
+    "@d8d/shared-test-util": "workspace:*",
+    "typescript": "^5.8.3",
+    "vitest": "^3.2.4",
+    "@vitest/coverage-v8": "^3.2.4"
+  },
+  "files": [
+    "src"
+  ]
+}

+ 80 - 0
packages/file-module/src/entities/file.entity.ts

@@ -0,0 +1,80 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
+import { UserEntity } from '@d8d/user-module/entities';
+import process from 'node:process';
+import { MinioService } from '../services/minio.service';
+
+@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, comment: '文件类型' })
+  type!: string | null;
+
+  @Column({ name: 'size', type: 'int', unsigned: true, nullable: true, comment: '文件大小,单位字节' })
+  size!: number | null;
+
+  @Column({ name: 'path', type: 'varchar', length: 512, comment: '文件存储路径' })
+  path!: string;
+
+  // 获取完整的文件URL(包含MINIO_HOST前缀)
+  // get fullUrl(): string {
+  //   const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http';
+  //   const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+  //   const host = process.env.MINIO_HOST || 'localhost';
+  //   const bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+  //   return `${protocol}://${host}${port}/${bucketName}/${this.path}`;
+  // }
+  get fullUrl(): Promise<string> {
+    // 创建MinioService实例
+    const minioService = new MinioService();
+    // 获取配置的桶名称
+    const bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+
+    // 返回一个Promise,内部处理异步获取URL的逻辑
+    return new Promise((resolve, reject) => {
+      // 调用minioService的异步方法
+      minioService.getPresignedFileUrl(bucketName, this.path)
+        .then(url => {
+          // 成功获取URL后解析Promise
+          resolve(url);
+        })
+        .catch(error => {
+          // 处理可能的错误
+          console.error('获取文件预签名URL失败:', error);
+          reject(error); // 将错误传递出去
+        });
+    });
+  }
+
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '文件描述' })
+  description!: string | null;
+
+  @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: 'timestamp' })
+  uploadTime!: Date;
+
+  @Column({ name: 'last_updated', type: 'timestamp', nullable: true, comment: '最后更新时间' })
+  lastUpdated!: Date | null;
+
+  @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;
+}

+ 1 - 0
packages/file-module/src/entities/index.ts

@@ -0,0 +1 @@
+export { File } from './file.entity';

+ 11 - 0
packages/file-module/src/index.ts

@@ -0,0 +1,11 @@
+// 导出实体
+export { File } from './entities';
+
+// 导出服务
+export { FileService, MinioService } from './services';
+
+// 导出Schema
+export * from './schemas';
+
+// 导出路由
+export { default as fileRoutes } from './routes';

+ 61 - 0
packages/file-module/src/routes/[id]/delete.ts

@@ -0,0 +1,61 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+// 删除文件路由
+const deleteFileRoute = createRoute({
+  method: 'delete',
+  path: '/{id}',
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().openapi({
+        param: { name: 'id', in: 'path' },
+        example: 1,
+        description: '文件ID'
+      })
+    })
+  },
+  responses: {
+    200: {
+      description: '文件删除成功',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean().openapi({ example: true }),
+            message: z.string().openapi({ example: '文件删除成功' })
+          })
+        }
+      }
+    },
+    404: {
+      description: '文件不存在',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(deleteFileRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+
+    // 创建文件服务实例
+    const fileService = new FileService(AppDataSource);
+    await fileService.deleteFile(id);
+    return c.json({ success: true, message: '文件删除成功' }, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '文件删除失败';
+    const code = (error instanceof Error && error.message === '文件不存在') ? 404 : 500;
+    return c.json({ code, message }, code);
+  }
+});
+
+export default app;

+ 67 - 0
packages/file-module/src/routes/[id]/download.ts

@@ -0,0 +1,67 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+// 获取文件下载URL路由
+const downloadFileRoute = createRoute({
+  method: 'get',
+  path: '/{id}/download',
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().openapi({
+        param: { name: 'id', in: 'path' },
+        example: 1,
+        description: '文件ID'
+      })
+    })
+  },
+  responses: {
+    200: {
+      description: '获取文件下载URL成功',
+      content: {
+        'application/json': {
+          schema: z.object({
+            url: z.string().url().openapi({
+              description: '文件下载URL(带Content-Disposition头)',
+              example: 'https://minio.example.com/bucket/file-key?response-content-disposition=attachment%3B%20filename%3D%22example.jpg%22'
+            }),
+            filename: z.string().openapi({
+              description: '原始文件名',
+              example: 'example.jpg'
+            })
+          })
+        }
+      }
+    },
+    404: {
+      description: '文件不存在',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(downloadFileRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+
+    // 创建文件服务实例
+    const fileService = new FileService(AppDataSource);
+    const result = await fileService.getFileDownloadUrl(id);
+    return c.json(result, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取文件下载URL失败';
+    const code = (error instanceof Error && error.message === '文件不存在') ? 404 : 500;
+    return c.json({ code, message }, code);
+  }
+});
+
+export default app;

+ 63 - 0
packages/file-module/src/routes/[id]/get-url.ts

@@ -0,0 +1,63 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+// 获取文件URL路由
+const getFileUrlRoute = createRoute({
+  method: 'get',
+  path: '/{id}/url',
+  middleware: [authMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number().openapi({
+        param: { name: 'id', in: 'path' },
+        example: 1,
+        description: '文件ID'
+      })
+    })
+  },
+  responses: {
+    200: {
+      description: '获取文件URL成功',
+      content: {
+        'application/json': {
+          schema: z.object({
+            url: z.string().url().openapi({
+              description: '文件访问URL',
+              example: 'https://minio.example.com/bucket/file-key'
+            })
+          })
+        }
+      }
+    },
+    404: {
+      description: '文件不存在',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(getFileUrlRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    // 创建文件服务实例
+    const fileService = new FileService(AppDataSource);
+    const url = await fileService.getFileUrl(id);
+    return c.json({ url }, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取文件URL失败';
+    const code = (error instanceof Error && error.message === '文件不存在') ? 404 : 500;
+    return c.json({ code, message }, code);
+  }
+});
+
+export default app;

+ 37 - 0
packages/file-module/src/routes/index.ts

@@ -0,0 +1,37 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import uploadPolicyRoute from './upload-policy/post';
+import multipartPolicyRoute from './multipart-policy/post';
+import completeMultipartRoute from './multipart-complete/post';
+import getUrlRoute from './[id]/get-url';
+import deleteRoute from './[id]/delete';
+import downloadRoute from './[id]/download';
+import { AuthContext } from '@d8d/shared-types';
+
+import { createCrudRoutes } from '@d8d/shared-crud';
+import { File } from '../entities/file.entity';
+import { FileSchema, CreateFileDto, UpdateFileDto } from '../schemas/file.schema';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+const fileRoutes = createCrudRoutes({
+  entity: File,
+  createSchema: CreateFileDto,
+  updateSchema: UpdateFileDto,
+  getSchema: FileSchema,
+  listSchema: FileSchema,
+  searchFields: ['name', 'type', 'description'],
+  relations: ['uploadUser'],
+  middleware: [authMiddleware]
+})
+
+
+// 创建路由实例并聚合所有子路由
+const app = new OpenAPIHono<AuthContext>()
+.route('/upload-policy', uploadPolicyRoute)
+.route('/multipart-policy', multipartPolicyRoute)
+.route('/multipart-complete', completeMultipartRoute)
+.route('/', getUrlRoute)
+.route('/', downloadRoute)
+.route('/', deleteRoute)
+.route('/', fileRoutes)
+
+export default app;

+ 123 - 0
packages/file-module/src/routes/multipart-complete/post.ts

@@ -0,0 +1,123 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+// 完成分片上传请求Schema
+const CompleteMultipartUploadDto = z.object({
+  uploadId: z.string().openapi({
+    description: '分片上传ID',
+    example: '123e4567-e89b-12d3-a456-426614174000'
+  }),
+  bucket: z.string().openapi({
+    description: '存储桶名称',
+    example: 'my-bucket'
+  }),
+  key: z.string().openapi({
+    description: '文件键名',
+    example: 'documents/report.pdf'
+  }),
+  parts: z.array(
+    z.object({
+      partNumber: z.coerce.number().int().positive().openapi({
+        description: '分片序号',
+        example: 1
+      }),
+      etag: z.string().openapi({
+        description: '分片ETag值',
+        example: 'd41d8cd98f00b204e9800998ecf8427e'
+      })
+    })
+  ).openapi({
+    description: '分片信息列表',
+    example: [
+      { partNumber: 1, etag: 'd41d8cd98f00b204e9800998ecf8427e' },
+      { partNumber: 2, etag: '5f4dcc3b5aa765d61d8327deb882cf99' }
+    ]
+  })
+});
+
+// 完成分片上传响应Schema
+const CompleteMultipartUploadResponse = z.object({
+  fileId: z.number().openapi({
+    description: '文件ID',
+    example: 123456
+  }),
+  url: z.string().openapi({
+    description: '文件访问URL',
+    example: 'https://minio.example.com/my-bucket/documents/report.pdf'
+  }),
+  host: z.string().openapi({
+    description: 'MinIO主机地址',
+    example: 'minio.example.com'
+  }),
+  bucket: z.string().openapi({
+    description: '存储桶名称',
+    example: 'my-bucket'
+  }),
+  key: z.string().openapi({
+    description: '文件键名',
+    example: 'documents/report.pdf'
+  }),
+  size: z.number().openapi({
+    description: '文件大小(字节)',
+    example: 102400
+  })
+});
+
+// 创建完成分片上传路由定义
+const completeMultipartUploadRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CompleteMultipartUploadDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '完成分片上传成功',
+      content: {
+        'application/json': { schema: CompleteMultipartUploadResponse }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建路由实例并实现处理逻辑
+const app = new OpenAPIHono<AuthContext>().openapi(completeMultipartUploadRoute, async (c) => {
+  try {
+    const data = await c.req.json();
+
+    // 初始化FileService
+    const fileService = new FileService(AppDataSource);
+    const result = await fileService.completeMultipartUpload(data);
+
+    // 构建完整的响应包含host和bucket信息
+    const response = {
+      ...result,
+      host: `${process.env.MINIO_USE_SSL ? 'https' : 'http'}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`,
+      bucket: data.bucket
+    };
+
+    return c.json(response, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '完成分片上传失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 116 - 0
packages/file-module/src/routes/multipart-policy/post.ts

@@ -0,0 +1,116 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+
+// 创建分片上传策略请求Schema
+const CreateMultipartUploadPolicyDto = z.object({
+fileKey: z.string().openapi({
+  description: '文件键名',
+  example: 'documents/report.pdf'
+}),
+totalSize: z.coerce.number().int().positive().openapi({
+  description: '文件总大小(字节)',
+  example: 10485760
+}),
+partSize: z.coerce.number().int().positive().openapi({
+  description: '分片大小(字节)',
+  example: 5242880
+}),
+type: z.string().max(50).nullable().optional().openapi({
+  description: '文件类型',
+  example: 'application/pdf'
+}),
+name: z.string().max(255).openapi({
+  description: '文件名称',
+  example: '项目计划书.pdf'
+})
+});
+
+// 创建分片上传策略路由定义
+const createMultipartUploadPolicyRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateMultipartUploadPolicyDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '生成分片上传策略成功',
+      content: {
+        'application/json': {
+          schema: z.object({
+            uploadId: z.string().openapi({
+              description: '分片上传ID',
+              example: '123e4567-e89b-12d3-a456-426614174000'
+            }),
+            bucket: z.string().openapi({
+              description: '存储桶名称',
+              example: 'my-bucket'
+            }),
+            key: z.string().openapi({
+              description: '文件键名',
+              example: 'documents/report.pdf'
+            }),
+            host: z.string().openapi({
+              description: 'MinIO主机地址',
+              example: 'minio.example.com'
+            }),
+            partUrls: z.array(z.string()).openapi({
+              description: '分片上传URL列表',
+              example: [
+                'https://minio.example.com/my-bucket/documents/report.pdf?uploadId=123e4567-e89b-12d3-a456-426614174000&partNumber=1',
+                'https://minio.example.com/my-bucket/documents/report.pdf?uploadId=123e4567-e89b-12d3-a456-426614174000&partNumber=2'
+              ]
+            })
+          })
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(createMultipartUploadPolicyRoute, async (c) => {
+try {
+  const data = await c.req.json();
+  const user = c.get('user');
+  // 计算分片数量
+  const partCount = Math.ceil(data.totalSize / data.partSize);
+  // 创建文件服务实例
+  const fileService = new FileService(AppDataSource);
+  const result = await fileService.createMultipartUploadPolicy({
+    ...data,
+    uploadUserId: user.id
+  }, partCount);
+
+  return c.json({
+    uploadId: result.uploadId,
+    bucket: result.bucket,
+    key: result.key,
+    host: `${process.env.MINIO_USE_SSL ? 'https' : 'http'}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`,
+    partUrls: result.uploadUrls
+  }, 200);
+} catch (error) {
+  const message = error instanceof Error ? error.message : '生成分片上传策略失败';
+  return c.json({ code: 500, message }, 500);
+}
+});
+
+export default app;

+ 90 - 0
packages/file-module/src/routes/upload-policy/post.ts

@@ -0,0 +1,90 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '../../services/file.service';
+import { FileSchema, CreateFileDto } from '../../schemas/file.schema';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { authMiddleware } from '@d8d/auth-module/middleware';
+import { parseWithAwait } from '@d8d/shared-utils';
+
+
+const CreateFileResponseSchema = z.object({
+            file: FileSchema,
+            uploadPolicy: z.object({
+              'x-amz-algorithm': z.string(),
+              'x-amz-credential': z.string(),
+              'x-amz-date': z.string(),
+              'x-amz-security-token': z.string().optional(),
+              policy: z.string(),
+              'x-amz-signature': z.string(),
+              host: z.string(),
+              key: z.string(),
+              bucket: z.string()
+            })
+          });
+
+// 创建文件上传策略路由
+const createUploadPolicyRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateFileDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '生成文件上传策略成功',
+      content: {
+        'application/json': {
+          schema: CreateFileResponseSchema
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(createUploadPolicyRoute, async (c) => {
+  try {
+    const data = await c.req.json();
+    const user = c.get('user');
+
+    // 创建文件服务实例
+    const fileService = new FileService(AppDataSource);
+
+    // 添加用户ID到文件数据
+    const fileData = {
+      ...data,
+      uploadUserId: user.id,
+      uploadTime: new Date()
+    };
+    const result = await fileService.createFile(fileData);
+    const typedResult = await parseWithAwait(CreateFileResponseSchema, result);
+    return c.json(typedResult, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json({
+        code: 400,
+        message: '参数错误',
+        errors: error.issues
+      }, 400);
+    }
+    const message = error instanceof Error ? error.message : '生成上传策略失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 92 - 0
packages/file-module/src/schemas/file.schema.ts

@@ -0,0 +1,92 @@
+import { z } from '@hono/zod-openapi';
+import { UserSchema } from '@d8d/user-module/schemas';
+
+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'
+  }),
+  fullUrl: z.url().openapi({
+    description: '完整文件访问URL',
+    example: 'https://minio.example.com/d8dai/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.coerce.date().openapi({
+    description: '上传时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  lastUpdated: z.date().nullable().openapi({
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z'
+  }),
+  createdAt: z.coerce.date().openapi({
+    description: '创建时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  updatedAt: z.coerce.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'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书(修订版)'
+  })
+});

+ 1 - 0
packages/file-module/src/schemas/index.ts

@@ -0,0 +1 @@
+export * from './file.schema';

+ 521 - 0
packages/file-module/src/services/file.service.ts

@@ -0,0 +1,521 @@
+import { GenericCrudService } from '@d8d/shared-crud';
+import { DataSource } from 'typeorm';
+import { File } from '../entities/file.entity';
+import { MinioService } from './minio.service';
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '@d8d/shared-utils';
+
+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) {
+    // 获取文件记录
+    const file = await this.getById(id);
+    if (!file) {
+      throw new Error('文件不存在');
+    }
+
+    try {
+      // 验证文件是否存在于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.getPresignedFileUrl(this.minioService.bucketName, file.path);
+  }
+
+  /**
+   * 获取文件下载URL(带Content-Disposition头)
+   */
+  async getFileDownloadUrl(id: number) {
+    const file = await this.getById(id);
+    if (!file) {
+      throw new Error('文件不存在');
+    }
+
+    const url = await this.minioService.getPresignedFileDownloadUrl(
+      this.minioService.bucketName,
+      file.path,
+      file.name
+    );
+
+    return {
+      url,
+      filename: file.name
+    };
+  }
+
+  /**
+   * 创建多部分上传策略
+   */
+  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 }>;
+  }) {
+    logger.db('Starting multipart upload completion:', {
+      uploadId: data.uploadId,
+      bucket: data.bucket,
+      key: data.key,
+      partsCount: data.parts.length
+    });
+
+    // 查找文件记录
+    const file = await this.repository.findOneBy({ path: data.key });
+    if (!file) {
+      throw new Error('文件记录不存在');
+    }
+
+    try {
+      // 完成MinIO分片上传 - 注意格式转换
+      const result = await this.minioService.completeMultipartUpload(
+        data.bucket,
+        data.key,
+        data.uploadId,
+        data.parts.map(part => ({ PartNumber: part.partNumber, ETag: part.etag }))
+      );
+
+      // 更新文件大小等信息
+      file.size = result.size;
+      file.updatedAt = new Date();
+      await this.repository.save(file);
+
+      // 生成文件访问URL
+      const url = this.minioService.getFileUrl(data.bucket, data.key);
+
+      logger.db('Multipart upload completed successfully:', {
+        fileId: file.id,
+        size: result.size,
+        key: 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('完成分片上传失败');
+    }
+  }
+
+  /**
+   * 保存文件记录并将文件内容直接上传到MinIO
+   * @param fileData - 文件基础信息
+   * @param fileContent - 文件内容(Buffer)
+   * @param contentType - 文件MIME类型
+   * @returns 保存的文件记录和文件访问URL
+   */
+  async saveFile(
+    fileData: {
+      name: string;
+      size: number;
+      mimeType: string;
+      uploadUserId: number;
+      [key: string]: any;
+    },
+    fileContent: Buffer,
+    contentType?: string
+  ) {
+    try {
+      logger.db('Starting saveFile process:', {
+        filename: fileData.name,
+        size: fileData.size,
+        mimeType: fileData.mimeType,
+        uploadUserId: fileData.uploadUserId
+      });
+
+      // 生成唯一文件存储路径
+      const fileKey = `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`;
+
+      // 确保存储桶存在
+      await this.minioService.ensureBucketExists();
+
+      // 直接上传文件内容到MinIO
+      const fileUrl = await this.minioService.createObject(
+        this.minioService.bucketName,
+        fileKey,
+        fileContent,
+        contentType || fileData.mimeType
+      );
+
+      // 准备文件记录数据
+      const completeFileData = {
+        ...fileData,
+        path: fileKey,
+        uploadTime: new Date(),
+      };
+
+      // 保存文件记录到数据库
+      const savedFile = await this.create(completeFileData as any);
+
+      logger.db('File saved successfully:', {
+        fileId: savedFile.id,
+        filename: savedFile.name,
+        size: savedFile.size,
+        url: fileUrl
+      });
+
+      return {
+        file: savedFile,
+        url: fileUrl
+      };
+    } catch (error) {
+      logger.error('Failed to save file:', error);
+      throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  }
+
+  /**
+   * 保存文件记录并将文件内容直接上传到MinIO(支持自定义存储路径)
+   * @param fileData - 文件基础信息
+   * @param fileContent - 文件内容(Buffer)
+   * @param customPath - 自定义存储路径(可选)
+   * @param contentType - 文件MIME类型
+   * @returns 保存的文件记录和文件访问URL
+   */
+  async saveFileWithCustomPath(
+    fileData: {
+      name: string;
+      size: number;
+      mimeType: string;
+      uploadUserId: number;
+      [key: string]: any;
+    },
+    fileContent: Buffer,
+    customPath?: string,
+    contentType?: string
+  ) {
+    try {
+      logger.db('Starting saveFileWithCustomPath process:', {
+        filename: fileData.name,
+        size: fileData.size,
+        mimeType: fileData.mimeType,
+        uploadUserId: fileData.uploadUserId,
+        customPath: customPath || 'auto-generated'
+      });
+
+      // 使用自定义路径或生成唯一文件存储路径
+      const fileKey = customPath || `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`;
+
+      // 确保存储桶存在
+      await this.minioService.ensureBucketExists();
+
+      // 直接上传文件内容到MinIO
+      const fileUrl = await this.minioService.createObject(
+        this.minioService.bucketName,
+        fileKey,
+        fileContent,
+        contentType || fileData.mimeType
+      );
+
+      // 准备文件记录数据
+      const completeFileData = {
+        ...fileData,
+        path: fileKey,
+        uploadTime: new Date(),
+        // createdAt: new Date(),
+        // updatedAt: new Date()
+      };
+
+      // 保存文件记录到数据库
+      const savedFile = await this.create(completeFileData as any);
+
+      logger.db('File saved with custom path successfully:', {
+        fileId: savedFile.id,
+        filename: savedFile.name,
+        size: savedFile.size,
+        path: fileKey,
+        url: fileUrl
+      });
+
+      return {
+        file: savedFile,
+        url: fileUrl
+      };
+    } catch (error) {
+      logger.error('Failed to save file with custom path:', error);
+      throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  }
+
+  /**
+   * 从URL下载文件并保存到MinIO
+   * @param url - 文件URL
+   * @param fileData - 文件基础信息(不含name和size,将自动获取)
+   * @param options - 可选配置
+   * @returns 保存的文件记录和文件访问URL
+   */
+  async downloadAndSaveFromUrl(
+    url: string,
+    fileData: {
+      uploadUserId: number;
+      mimeType?: string;
+      customFileName?: string;
+      customPath?: string;
+      [key: string]: any;
+    },
+    options?: {
+      timeout?: number;
+      retries?: number;
+    }
+  ) {
+    try {
+      const axios = require('axios');
+
+      logger.db('Starting downloadAndSaveFromUrl process:', {
+        url,
+        uploadUserId: fileData.uploadUserId,
+        customFileName: fileData.customFileName,
+        customPath: fileData.customPath
+      });
+
+      // 下载文件
+      const response = await axios.get(url, {
+        responseType: 'arraybuffer',
+        timeout: options?.timeout || 30000,
+        maxRedirects: 5,
+        headers: {
+          'User-Agent': 'Mozilla/5.0 (compatible; FileDownloader/1.0)'
+        }
+      });
+
+      const buffer = Buffer.from(response.data);
+
+      // 从URL或响应头中获取文件名
+      let fileName = fileData.customFileName;
+      if (!fileName) {
+        // 尝试从Content-Disposition头获取文件名
+        const contentDisposition = response.headers['content-disposition'];
+        if (contentDisposition) {
+          const filenameMatch = contentDisposition.match(/filename[*]?=(?:utf-8'')?(.+)/i);
+          if (filenameMatch) {
+            fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
+          }
+        }
+
+        // 从URL路径获取文件名
+        if (!fileName) {
+          const urlPath = new URL(url).pathname;
+          fileName = urlPath.split('/').pop() || `file_${Date.now()}`;
+        }
+      }
+
+      // 确保文件有扩展名
+      if (!fileName.includes('.') && fileData.mimeType) {
+        const ext = this.getExtensionFromMimeType(fileData.mimeType);
+        if (ext) {
+          fileName += `.${ext}`;
+        }
+      }
+
+      // 确定MIME类型
+      let mimeType = fileData.mimeType || response.headers['content-type'];
+      if (!mimeType || mimeType === 'application/octet-stream') {
+        mimeType = this.inferMimeType(fileName);
+      }
+
+      // 保存文件
+      const saveResult = await this.saveFileWithCustomPath(
+        {
+          ...fileData,
+          name: fileName,
+          size: buffer.length,
+          mimeType,
+          fileType: this.getFileTypeFromMimeType(mimeType)
+        },
+        buffer,
+        fileData.customPath,
+        mimeType
+      );
+
+      logger.db('Download and save completed successfully:', {
+        fileId: saveResult.file.id,
+        fileName,
+        size: buffer.length,
+        url: saveResult.url
+      });
+
+      return saveResult;
+    } catch (error) {
+      logger.error('Failed to download and save file from URL:', {
+        url,
+        error: error instanceof Error ? error.message : '未知错误',
+        stack: error instanceof Error ? error.stack : undefined
+      });
+      throw new Error(`从URL下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  }
+
+  /**
+   * 根据MIME类型获取文件扩展名
+   */
+  private getExtensionFromMimeType(mimeType: string): string | null {
+    const mimeMap: Record<string, string> = {
+      'image/jpeg': 'jpg',
+      'image/png': 'png',
+      'image/gif': 'gif',
+      'image/webp': 'webp',
+      'image/svg+xml': 'svg',
+      'application/pdf': 'pdf',
+      'text/plain': 'txt',
+      'application/json': 'json',
+      'application/xml': 'xml',
+      'video/mp4': 'mp4',
+      'audio/mp3': 'mp3'
+    };
+    return mimeMap[mimeType] || null;
+  }
+
+  /**
+   * 根据文件名推断MIME类型
+   */
+  private inferMimeType(fileName: string): string {
+    const ext = fileName.toLowerCase().split('.').pop();
+    const extMap: Record<string, string> = {
+      'jpg': 'image/jpeg',
+      'jpeg': 'image/jpeg',
+      'png': 'image/png',
+      'gif': 'image/gif',
+      'webp': 'image/webp',
+      'svg': 'image/svg+xml',
+      'pdf': 'application/pdf',
+      'txt': 'text/plain',
+      'json': 'application/json',
+      'xml': 'application/xml',
+      'mp4': 'video/mp4',
+      'mp3': 'audio/mp3',
+      'wav': 'audio/wav'
+    };
+    return extMap[ext || ''] || 'application/octet-stream';
+  }
+
+  /**
+   * 根据MIME类型获取文件类型
+   */
+  private getFileTypeFromMimeType(mimeType: string): string {
+    if (mimeType.startsWith('image/')) return 'image';
+    if (mimeType.startsWith('video/')) return 'video';
+    if (mimeType.startsWith('audio/')) return 'audio';
+    if (mimeType === 'application/pdf') return 'document';
+    if (mimeType.startsWith('text/')) return 'document';
+    return 'other';
+  }
+}

+ 2 - 0
packages/file-module/src/services/index.ts

@@ -0,0 +1,2 @@
+export { FileService } from './file.service';
+export { MinioService } from './minio.service';

+ 236 - 0
packages/file-module/src/services/minio.service.ts

@@ -0,0 +1,236 @@
+import { Client } from 'minio';
+import { logger } from '@d8d/shared-utils';
+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 !== 'false' ? 'https' : 'http';
+    const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+    return `${protocol}://${process.env.MINIO_HOST}${port}/${bucketName}/${fileKey}`;
+  }
+
+  // 生成预签名文件访问URL(用于私有bucket)
+  async getPresignedFileUrl(bucketName: string, fileKey: string, expiresInSeconds = 3600) {
+    try {
+      const url = await this.client.presignedGetObject(bucketName, fileKey, expiresInSeconds);
+      logger.db(`Generated presigned URL for ${bucketName}/${fileKey}, expires in ${expiresInSeconds}s`);
+      return url;
+    } catch (error) {
+      logger.error(`Failed to generate presigned URL for ${bucketName}/${fileKey}:`, error);
+      throw error;
+    }
+  }
+
+  // 生成预签名文件下载URL(带Content-Disposition头)
+  async getPresignedFileDownloadUrl(bucketName: string, fileKey: string, filename: string, expiresInSeconds = 3600) {
+    try {
+      const url = await this.client.presignedGetObject(
+        bucketName,
+        fileKey,
+        expiresInSeconds,
+        {
+          'response-content-disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
+          'response-content-type': 'application/octet-stream'
+        }
+      );
+      logger.db(`Generated presigned download URL for ${bucketName}/${fileKey}, filename: ${filename}`);
+      return url;
+    } catch (error) {
+      logger.error(`Failed to generate presigned download URL for ${bucketName}/${fileKey}:`, error);
+      throw error;
+    }
+  }
+
+  // 创建分段上传会话
+  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;
+    }
+  }
+}

+ 16 - 0
packages/file-module/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "rootDir": ".",
+    "outDir": "dist"
+  },
+  "include": [
+    "src/**/*",
+    "tests/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}