Bläddra i källkod

✨ feat(shared-utils): 新增错误模式导出和Zod错误处理函数

- 在package.json中新增错误模式导出路径
- 创建error.schema.ts文件,定义ErrorSchema、ZodIssueSchema和ZodErrorSchema
- 在errorHandler.ts中导出新增的错误模式
- 新增handleZodError函数,提供类型安全的Zod错误处理
- 新增createZodErrorResponse函数,为路由提供简化的Zod错误响应

✨ feat(unified-file-module): 新增文件操作路由和分片上传功能

- 新增文件删除路由(/[id]/delete),支持通过ID删除文件
- 新增文件下载路由(/[id]/download),支持获取带Content-Disposition头的下载URL
- 新增文件URL获取路由(/[id]/get-url),支持获取文件访问URL
- 新增分片上传策略路由(/multipart-policy/post),支持生成分片上传策略
- 新增完成分片上传路由(/multipart-complete/post),支持完成分片上传
- 新增文件上传策略路由(/upload-policy/post),支持生成文件上传策略
- 重构routes/index.ts,聚合所有子路由并集成CRUD功能
- 在MinioService中新增分片上传相关方法【createMultipartUpload、generateMultipartUploadUrls、completeMultipartUpload】
- 在UnifiedFileService中新增分片上传相关方法【getFileDownloadUrl、createMultipartUploadPolicy、completeMultipartUpload】

♻️ refactor(unified-file-module): 重构集成测试和工具类

- 重构集成测试文件路径引用,将$get和$post方法调用改为index.$get和index.$post
- 新增集成测试数据库工具类(integration-test-db.ts),提供测试数据工厂方法
- 新增集成测试断言工具类(integration-test-utils.ts),提供响应状态、结构和数据库存在性断言
yourname 2 veckor sedan
förälder
incheckning
143a703f09

+ 5 - 0
packages/shared-utils/package.json

@@ -10,6 +10,11 @@
       "types": "./src/index.ts",
       "import": "./src/index.ts",
       "require": "./src/index.ts"
+    },
+    "./schema/error": {
+      "types": "./src/schema/error.schema.ts",
+      "import": "./src/schema/error.schema.ts",
+      "require": "./src/schema/error.schema.ts"
     }
   },
   "scripts": {

+ 88 - 0
packages/shared-utils/src/schema/error.schema.ts

@@ -0,0 +1,88 @@
+import { z } from '@hono/zod-openapi'
+
+export const ErrorSchema = z.object({
+  code: z.number().openapi({
+    example: 400,
+  }),
+  message: z.string().openapi({
+    example: 'Bad Request',
+  }),
+})
+
+// Zod错误详情schema - 类型安全版本
+export const ZodIssueSchema = z.object({
+  code: z.string().openapi({
+    example: 'invalid_type',
+  }),
+  expected: z.string().optional().openapi({
+    example: 'number',
+  }),
+  received: z.string().optional().openapi({
+    example: 'string',
+  }),
+  path: z.array(z.union([z.string(), z.number(), z.null()])).openapi({
+    example: ['basicSalary'],
+  }),
+  message: z.string().openapi({
+    example: '基本工资必须大于0',
+  }),
+  origin: z.string().optional().openapi({
+    example: 'value',
+  }),
+  minimum: z.number().optional().openapi({
+    example: 0,
+  }),
+  maximum: z.number().optional().openapi({
+    example: 100,
+  }),
+  inclusive: z.boolean().optional().openapi({
+    example: false,
+  }),
+  // 添加更多可能的字段
+  input: z.any().optional().openapi({
+    description: '原始输入数据(当reportInput为true时)',
+  }),
+  fatal: z.boolean().optional().openapi({
+    example: false,
+  }),
+  type: z.string().optional().openapi({
+    example: 'string',
+  }),
+  exact: z.boolean().optional().openapi({
+    example: false,
+  }),
+  validation: z.string().optional().openapi({
+    example: 'email',
+  }),
+  keys: z.array(z.string()).optional().openapi({
+    example: ['extraField'],
+  }),
+  options: z.array(z.union([z.string(), z.number()])).optional().openapi({
+    example: ['option1', 'option2'],
+  }),
+  multipleOf: z.number().optional().openapi({
+    example: 5,
+  }),
+  unionErrors: z.array(z.any()).optional().openapi({
+    description: '联合类型错误详情',
+  }),
+  argumentsError: z.any().optional().openapi({
+    description: '参数错误详情',
+  }),
+  returnTypeError: z.any().optional().openapi({
+    description: '返回类型错误详情',
+  }),
+})
+
+// Zod错误响应schema
+export const ZodErrorSchema = z.object({
+  code: z.number().openapi({
+    example: 400,
+  }),
+  message: z.string().openapi({
+    example: '参数错误',
+  }),
+  errors: z.array(ZodIssueSchema).openapi({
+    description: 'Zod验证错误详情',
+  }),
+})

+ 118 - 8
packages/shared-utils/src/utils/errorHandler.ts

@@ -1,14 +1,124 @@
 import { Context } from 'hono'
 import { z } from '@hono/zod-openapi'
+import { ErrorSchema, ZodIssueSchema, ZodErrorSchema } from '../schema/error.schema'
 
-export const ErrorSchema = z.object({
-  code: z.number().openapi({
-    example: 400,
-  }),
-  message: z.string().openapi({
-    example: 'Bad Request',
-  }),
-})
+export { ErrorSchema, ZodIssueSchema, ZodErrorSchema }
+
+// 类型安全的Zod错误处理函数
+export function handleZodError(error: z.ZodError): z.infer<typeof ZodErrorSchema> {
+  const issues = error.issues.map(issue => {
+    // 基础字段 - 过滤掉symbol类型的path,转换null为字符串
+    const mappedIssue: z.infer<typeof ZodIssueSchema> = {
+      code: issue.code,
+      path: issue.path.map(p => {
+        if (typeof p === 'string' || typeof p === 'number') {
+          return p
+        }
+        // 将symbol和null转换为字符串
+        return String(p)
+      }),
+      message: issue.message,
+    }
+
+    // 根据错误类型添加特定字段
+    if ('expected' in issue && issue.expected !== undefined) {
+      mappedIssue.expected = String(issue.expected)
+    }
+
+    if ('received' in issue && issue.received !== undefined) {
+      mappedIssue.received = String(issue.received)
+    }
+
+    if ('origin' in issue && issue.origin !== undefined) {
+      mappedIssue.origin = String(issue.origin)
+    }
+
+    if ('minimum' in issue && issue.minimum !== undefined) {
+      mappedIssue.minimum = Number(issue.minimum)
+    }
+
+    if ('maximum' in issue && issue.maximum !== undefined) {
+      mappedIssue.maximum = Number(issue.maximum)
+    }
+
+    if ('inclusive' in issue && issue.inclusive !== undefined) {
+      mappedIssue.inclusive = Boolean(issue.inclusive)
+    }
+
+    if ('fatal' in issue && issue.fatal !== undefined) {
+      mappedIssue.fatal = Boolean(issue.fatal)
+    }
+
+    if ('type' in issue && issue.type !== undefined) {
+      mappedIssue.type = String(issue.type)
+    }
+
+    if ('exact' in issue && issue.exact !== undefined) {
+      mappedIssue.exact = Boolean(issue.exact)
+    }
+
+    if ('validation' in issue && issue.validation !== undefined) {
+      mappedIssue.validation = String(issue.validation)
+    }
+
+    if ('keys' in issue && issue.keys !== undefined && Array.isArray(issue.keys)) {
+      mappedIssue.keys = issue.keys.map(key => String(key))
+    }
+
+    if ('options' in issue && issue.options !== undefined && Array.isArray(issue.options)) {
+      mappedIssue.options = issue.options.map(opt =>
+        typeof opt === 'string' || typeof opt === 'number' ? opt : String(opt)
+      )
+    }
+
+    if ('multipleOf' in issue && issue.multipleOf !== undefined) {
+      mappedIssue.multipleOf = Number(issue.multipleOf)
+    }
+
+    if ('input' in issue && issue.input !== undefined) {
+      mappedIssue.input = issue.input
+    }
+
+    if ('unionErrors' in issue && issue.unionErrors !== undefined && Array.isArray(issue.unionErrors)) {
+      mappedIssue.unionErrors = issue.unionErrors
+    }
+
+    if ('argumentsError' in issue && issue.argumentsError !== undefined) {
+      mappedIssue.argumentsError = issue.argumentsError
+    }
+
+    if ('returnTypeError' in issue && issue.returnTypeError !== undefined) {
+      mappedIssue.returnTypeError = issue.returnTypeError
+    }
+
+    return mappedIssue
+  })
+
+  return {
+    code: 400,
+    message: '参数错误',
+    errors: issues,
+  }
+}
+
+// 简化的Zod错误处理函数(用于路由)
+export function createZodErrorResponse(error: z.ZodError) {
+  return {
+    code: 400,
+    message: '参数错误',
+    errors: error.issues.map(issue => ({
+      code: issue.code,
+      path: issue.path,
+      message: issue.message,
+      // 可选字段 - 根据实际需要添加
+      ...('expected' in issue && issue.expected !== undefined && { expected: String(issue.expected) }),
+      ...('received' in issue && issue.received !== undefined && { received: String(issue.received) }),
+      ...('minimum' in issue && issue.minimum !== undefined && { minimum: Number(issue.minimum) }),
+      ...('maximum' in issue && issue.maximum !== undefined && { maximum: Number(issue.maximum) }),
+      ...('inclusive' in issue && issue.inclusive !== undefined && { inclusive: Boolean(issue.inclusive) }),
+    }))
+  }
+}
 
 export const errorHandler = async (err: Error, c: Context) => {
   return c.json(

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

@@ -0,0 +1,77 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
+
+// 删除文件路由
+const deleteFileRoute = createRoute({
+  method: 'delete',
+  path: '/{id}',
+  middleware: [tenantAuthMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number<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: '文件删除成功' })
+          })
+        }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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 unifiedFileService = new UnifiedFileService(AppDataSource);
+    await unifiedFileService.deleteFile(id);
+
+    const validatedResponse = await parseWithAwait(
+      z.object({
+        success: z.boolean(),
+        message: z.string()
+      }),
+      { success: true, message: '文件删除成功' }
+    );
+    return c.json(validatedResponse, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    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;

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

@@ -0,0 +1,83 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait } from '@d8d/shared-utils';function createZodErrorResponse(e: Error){return{code:400,message:"Validation failed"}};
+
+// 获取文件下载URL路由
+const downloadFileRoute = createRoute({
+  method: 'get',
+  path: '/{id}/download',
+  middleware: [tenantAuthMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number<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/d8dai/file-key?response-content-disposition=attachment%3B%20filename%3D%22example.jpg%22'
+            }),
+            filename: z.string().openapi({
+              description: '原始文件名',
+              example: 'example.jpg'
+            })
+          })
+        }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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 unifiedFileService = new UnifiedFileService(AppDataSource);
+    const result = await unifiedFileService.getFileDownloadUrl(id);
+
+    const validatedResponse = await parseWithAwait(
+      z.object({
+        url: z.string().url(),
+        filename: z.string()
+      }),
+      result
+    );
+    return c.json(validatedResponse, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    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;

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

@@ -0,0 +1,75 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
+
+// 获取文件URL路由
+const getFileUrlRoute = createRoute({
+  method: 'get',
+  path: '/{id}/url',
+  middleware: [tenantAuthMiddleware],
+  request: {
+    params: z.object({
+      id: z.coerce.number<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/d8dai/file-key'
+            })
+          })
+        }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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 unifiedFileService = new UnifiedFileService(AppDataSource);
+    const url = await unifiedFileService.getFileUrl(id);
+
+    const validatedResponse = await parseWithAwait(
+      z.object({ url: z.string().url() }),
+      { url }
+    );
+    return c.json(validatedResponse, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    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 - 1
packages/unified-file-module/src/routes/index.ts

@@ -1 +1,37 @@
-export { default as unifiedFilesAdminRoutes } from "./admin/unified-files.admin.routes";
+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 { UnifiedFile } from '../entities/unified-file.entity';
+import { UnifiedFileSchema, CreateUnifiedFileDto, UpdateUnifiedFileDto } from '../schemas/unified-file.schema';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+
+const unifiedFileCrudRoutes = createCrudRoutes({
+  entity: UnifiedFile,
+  createSchema: CreateUnifiedFileDto,
+  updateSchema: UpdateUnifiedFileDto,
+  getSchema: UnifiedFileSchema,
+  listSchema: UnifiedFileSchema,
+  searchFields: ['fileName', 'mimeType', 'description'],
+  relations: [],
+  middleware: [tenantAuthMiddleware]
+});
+
+// 创建路由实例并聚合所有子路由
+const unifiedFileRoutes = new OpenAPIHono<AuthContext>()
+  .route('/upload-policy', uploadPolicyRoute)
+  .route('/multipart-policy', multipartPolicyRoute)
+  .route('/multipart-complete', completeMultipartRoute)
+  .route('/', getUrlRoute)
+  .route('/', downloadRoute)
+  .route('/', deleteRoute)
+  .route('/', unifiedFileCrudRoutes);
+
+export { unifiedFileRoutes };
+export default unifiedFileRoutes;

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

@@ -0,0 +1,142 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
+
+// 完成分片上传请求Schema
+const CompleteMultipartUploadDto = z.object({
+  uploadId: z.string().openapi({
+    description: '分片上传ID',
+    example: '123e4567-e89b-12d3-a456-426614174000'
+  }),
+  bucket: z.string().openapi({
+    description: '存储桶名称',
+    example: 'd8dai'
+  }),
+  key: z.string().openapi({
+    description: '文件键名',
+    example: 'documents/report.pdf'
+  }),
+  parts: z.array(
+    z.object({
+      partNumber: z.coerce.number<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().url().openapi({
+    description: '文件访问URL',
+    example: 'https://minio.example.com/d8dai/documents/report.pdf'
+  }),
+  host: z.string().openapi({
+    description: 'MinIO主机地址',
+    example: 'minio.example.com'
+  }),
+  bucket: z.string().openapi({
+    description: '存储桶名称',
+    example: 'd8dai'
+  }),
+  key: z.string().openapi({
+    description: '文件键名',
+    example: 'documents/report.pdf'
+  }),
+  size: z.number().openapi({
+    description: '文件大小(字节)',
+    example: 102400
+  })
+});
+
+// 创建完成分片上传路由定义
+const completeMultipartUploadRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [tenantAuthMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CompleteMultipartUploadDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '完成分片上传成功',
+      content: {
+        'application/json': { schema: CompleteMultipartUploadResponse }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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();
+
+    // 初始化UnifiedFileService
+    const unifiedFileService = new UnifiedFileService(AppDataSource);
+    const result = await unifiedFileService.completeMultipartUpload(data);
+
+    // 构建完整的响应包含host和bucket信息
+    const response = {
+      ...result,
+      host: `${process.env.MINIO_USE_SSL ? 'https' : 'http'}://${process.env.MINIO_ENDPOINT || process.env.MINIO_HOST}:${process.env.MINIO_PORT}`,
+      bucket: data.bucket
+    };
+
+    const validatedResponse = await parseWithAwait(
+      z.object({
+        fileId: z.number(),
+        url: z.string().url(),
+        host: z.string(),
+        bucket: z.string(),
+        key: z.string(),
+        size: z.number()
+      }),
+      response
+    );
+    return c.json(validatedResponse, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    const message = error instanceof Error ? error.message : '完成分片上传失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

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

@@ -0,0 +1,138 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
+
+// 创建分片上传策略请求Schema
+const CreateMultipartUploadPolicyDto = z.object({
+fileKey: z.string().openapi({
+  description: '文件键名',
+  example: 'documents/report.pdf'
+}),
+totalSize: z.coerce.number<number>().int().positive().openapi({
+  description: '文件总大小(字节)',
+  example: 10485760
+}),
+partSize: z.coerce.number<number>().int().positive().openapi({
+  description: '分片大小(字节)',
+  example: 5242880
+}),
+mimeType: z.string().max(100).nullable().optional().openapi({
+  description: '文件类型',
+  example: 'application/pdf'
+}),
+fileName: z.string().max(255).openapi({
+  description: '文件名称',
+  example: '项目计划书.pdf'
+})
+});
+
+// 创建分片上传策略路由定义
+const createMultipartUploadPolicyRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [tenantAuthMiddleware],
+  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: 'd8dai'
+            }),
+            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/d8dai/documents/report.pdf?uploadId=123e4567-e89b-12d3-a456-426614174000&partNumber=1',
+                'https://minio.example.com/d8dai/documents/report.pdf?uploadId=123e4567-e89b-12d3-a456-426614174000&partNumber=2'
+              ]
+            })
+          })
+        }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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 unifiedFileService = new UnifiedFileService(AppDataSource);
+    const result = await unifiedFileService.createMultipartUploadPolicy({
+      ...data,
+      createdBy: user.id
+    }, partCount);
+
+    const response = {
+      uploadId: result.uploadId,
+      bucket: result.bucket,
+      key: result.key,
+      host: `${process.env.MINIO_USE_SSL ? 'https' : 'http'}://${process.env.MINIO_ENDPOINT || process.env.MINIO_HOST}:${process.env.MINIO_PORT}`,
+      partUrls: result.uploadUrls
+    };
+
+    const validatedResponse = await parseWithAwait(
+      z.object({
+        uploadId: z.string(),
+        bucket: z.string(),
+        key: z.string(),
+        host: z.string(),
+        partUrls: z.array(z.string())
+      }),
+      response
+    );
+    return c.json(validatedResponse, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    const message = error instanceof Error ? error.message : '生成分片上传策略失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

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

@@ -0,0 +1,89 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { UnifiedFileService } from '../../services/unified-file.service';
+import { UnifiedFileSchema, CreateUnifiedFileDto } from '../../schemas/unified-file.schema';
+import { ErrorSchema } from '@d8d/shared-utils';
+import { AppDataSource } from '@d8d/shared-utils';
+import { AuthContext } from '@d8d/shared-types';
+import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
+import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
+
+const CreateUnifiedFileResponseSchema = z.object({
+  file: UnifiedFileSchema,
+  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: [tenantAuthMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateUnifiedFileDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '生成文件上传策略成功',
+      content: {
+        'application/json': {
+          schema: CreateUnifiedFileResponseSchema
+        }
+      }
+    },
+    401: {
+      description: '未授权访问',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    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 = c.req.valid('json');
+    const user = c.get('user');
+
+    // 创建文件服务实例
+    const unifiedFileService = new UnifiedFileService(AppDataSource);
+
+    // 添加用户ID到文件数据
+    const fileData = {
+      ...data,
+      createdBy: user.id
+    };
+    const result = await unifiedFileService.createFile(fileData);
+
+    // 使用 parseWithAwait 验证响应数据
+    const validatedResult = await parseWithAwait(CreateUnifiedFileResponseSchema, result);
+    return c.json(validatedResult, 200);
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return c.json(createZodErrorResponse(error), 400);
+    }
+    const message = error instanceof Error ? error.message : '生成上传策略失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

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

@@ -124,4 +124,77 @@ export class MinioService {
       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;
+    }
+  }
 }

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

@@ -128,6 +128,132 @@ export class UnifiedFileService extends GenericCrudService<UnifiedFile> {
     return this.minioService.getPresignedFileUrl(this.minioService.bucketName, file.filePath);
   }
 
+  /**
+   * 获取文件下载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.filePath,
+      file.fileName
+    );
+
+    return {
+      url,
+      filename: file.fileName
+    };
+  }
+
+  /**
+   * 创建多部分上传策略
+   */
+  async createMultipartUploadPolicy(data: Partial<UnifiedFile>, partCount: number) {
+    try {
+      // 生成唯一文件存储路径
+      const fileKey = `unified/${uuidv4()}-${data.fileName}`;
+
+      // 初始化多部分上传
+      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,
+        filePath: fileKey,
+        status: 1,
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+
+      // 保存文件记录到数据库
+      const savedFile = await this.create(fileData as UnifiedFile);
+
+      // 返回文件记录和上传策略
+      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({ filePath: 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.fileSize = 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(支持自定义存储路径)
    */

+ 5 - 5
packages/unified-file-module/tests/integration/unified-files.integration.test.ts

@@ -34,7 +34,7 @@ describe('统一文件模块集成测试', () => {
 
     describe('GET /admin/unified-files', () => {
       it('应该允许超级管理员获取文件列表', async () => {
-        const response = await adminClient.$get({
+        const response = await adminClient.index.$get({
           query: { page: 1, pageSize: 10 }
         }, {
           headers: {
@@ -55,7 +55,7 @@ describe('统一文件模块集成测试', () => {
       });
 
       it('应该拒绝普通用户访问管理员接口', async () => {
-        const response = await adminClient.$get({
+        const response = await adminClient.index.$get({
           query: { page: 1, pageSize: 10 }
         }, {
           headers: {
@@ -67,7 +67,7 @@ describe('统一文件模块集成测试', () => {
       });
 
       it('应该拒绝未认证用户访问', async () => {
-        const response = await adminClient.$get({
+        const response = await adminClient.index.$get({
           query: { page: 1, pageSize: 10 }
         });
 
@@ -85,7 +85,7 @@ describe('统一文件模块集成测试', () => {
           status: 1
         };
 
-        const response = await adminClient.$post({
+        const response = await adminClient.index.$post({
           json: newFile
         }, {
           headers: {
@@ -105,7 +105,7 @@ describe('统一文件模块集成测试', () => {
           mimeType: 'image/jpeg'
         };
 
-        const response = await adminClient.$post({
+        const response = await adminClient.index.$post({
           json: newFile
         }, {
           headers: {

+ 34 - 0
packages/unified-file-module/tests/utils/integration-test-db.ts

@@ -0,0 +1,34 @@
+import { DataSource } from "typeorm";
+import { UnifiedFile } from "../../src/entities";
+
+/**
+ * 测试数据工厂类
+ */
+export class TestDataFactory {
+  /**
+   * 创建测试文件数据
+   */
+  static createFileData(overrides: Partial<UnifiedFile> = {}): Partial<UnifiedFile> {
+    const timestamp = Date.now();
+    return {
+      fileName: `testfile_${timestamp}.txt`,
+      mimeType: "text/plain",
+      fileSize: 1024,
+      filePath: `/uploads/testfile_${timestamp}.txt`,
+      description: `Test file ${timestamp}`,
+      createdAt: new Date(),
+      ...overrides
+    };
+  }
+
+  /**
+   * 在数据库中创建测试文件
+   */
+  static async createTestFile(dataSource: DataSource, overrides: Partial<UnifiedFile> = {}): Promise<UnifiedFile> {
+    const fileData = this.createFileData(overrides);
+    const fileRepository = dataSource.getRepository(UnifiedFile);
+
+    const file = fileRepository.create(fileData);
+    return await fileRepository.save(file);
+  }
+}

+ 106 - 0
packages/unified-file-module/tests/utils/integration-test-utils.ts

@@ -0,0 +1,106 @@
+import { IntegrationTestDatabase } from "@d8d/shared-test-util";
+import { UnifiedFile } from "../../src/entities";
+
+/**
+ * 集成测试断言工具
+ */
+export class IntegrationTestAssertions {
+  /**
+   * 断言响应状态码
+   */
+  static expectStatus(response: { status: number }, expectedStatus: number): void {
+    if (response.status !== expectedStatus) {
+      throw new Error(`Expected status ${expectedStatus}, but got ${response.status}`);
+    }
+  }
+
+  /**
+   * 断言响应包含特定字段
+   */
+  static expectResponseToHave(response: { data: any }, expectedFields: Record<string, any>): void {
+    for (const [key, value] of Object.entries(expectedFields)) {
+      if (response.data[key] !== value) {
+        throw new Error(`Expected field ${key} to be ${value}, but got ${response.data[key]}`);
+      }
+    }
+  }
+
+  /**
+   * 断言响应包含特定结构
+   */
+  static expectResponseStructure(response: { data: any }, structure: Record<string, any>): void {
+    for (const key of Object.keys(structure)) {
+      if (!(key in response.data)) {
+        throw new Error(`Expected response to have key: ${key}`);
+      }
+    }
+  }
+
+  /**
+   * 断言文件存在于数据库中
+   */
+  static async expectFileToExist(fileName: string): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error("Database not initialized");
+    }
+
+    const fileRepository = dataSource.getRepository(UnifiedFile);
+    const file = await fileRepository.findOne({ where: { fileName } });
+
+    if (!file) {
+      throw new Error(`Expected file ${fileName} to exist in database`);
+    }
+  }
+
+  /**
+   * 断言文件不存在于数据库中
+   */
+  static async expectFileNotToExist(fileName: string): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error("Database not initialized");
+    }
+
+    const fileRepository = dataSource.getRepository(UnifiedFile);
+    const file = await fileRepository.findOne({ where: { fileName } });
+
+    if (file) {
+      throw new Error(`Expected file ${fileName} not to exist in database`);
+    }
+  }
+
+  /**
+   * 断言文件存在于数据库中(通过ID)
+   */
+  static async expectFileToExistById(id: number): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error("Database not initialized");
+    }
+
+    const fileRepository = dataSource.getRepository(UnifiedFile);
+    const file = await fileRepository.findOne({ where: { id } });
+
+    if (!file) {
+      throw new Error(`Expected file with ID ${id} to exist in database`);
+    }
+  }
+
+  /**
+   * 断言文件不存在于数据库中(通过ID)
+   */
+  static async expectFileNotToExistById(id: number): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error("Database not initialized");
+    }
+
+    const fileRepository = dataSource.getRepository(UnifiedFile);
+    const file = await fileRepository.findOne({ where: { id } });
+
+    if (file) {
+      throw new Error(`Expected file with ID ${id} not to exist in database`);
+    }
+  }
+}