Selaa lähdekoodia

✨ feat(files): add file management API endpoints

- add file routes module with CRUD operations
- implement file upload policy generation endpoint
- add multipart upload policy and completion endpoints
- create file URL retrieval and deletion endpoints
- integrate authentication middleware for all file operations
- register file API routes at /api/v1/files
yourname 4 kuukautta sitten
vanhempi
sitoutus
54da280709

+ 2 - 0
src/server/api.ts

@@ -3,6 +3,7 @@ import { errorHandler } from './utils/errorHandler'
 import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
 import rolesRoute from './api/roles/index'
+import fileRoutes from './api/files/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 
@@ -53,6 +54,7 @@ if(!import.meta.env.PROD){
 const userRoutes = api.route('/api/v1/users', usersRouter)
 const authRoutes = api.route('/api/v1/auth', authRoute)
 const roleRoutes = api.route('/api/v1/roles', rolesRoute)
+const fileApiRoutes = api.route('/api/v1/files', fileRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes

+ 61 - 0
src/server/api/files/[id]/delete.ts

@@ -0,0 +1,61 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '@/server/modules/files/file.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 删除文件路由
+const deleteFileRoute = createRoute({
+  method: 'delete',
+  path: '/',
+  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 fileService = new FileService(AppDataSource);
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(deleteFileRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    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;

+ 64 - 0
src/server/api/files/[id]/get-url.ts

@@ -0,0 +1,64 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '@/server/modules/files/file.service';
+import { MinioService } from '@/server/modules/files/minio.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 获取文件URL路由
+const getFileUrlRoute = createRoute({
+  method: 'get',
+  path: '/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 fileService = new FileService(AppDataSource);
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(getFileUrlRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    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;

+ 35 - 0
src/server/api/files/index.ts

@@ -0,0 +1,35 @@
+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 { AuthContext } from '@/server/types/context';
+
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { File } from '@/server/modules/files/file.entity';
+import { FileSchema, CreateFileDto, UpdateFileDto } from '@/server/modules/files/file.entity';
+import { authMiddleware } from '@/server/middleware/auth.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('/{id}', getUrlRoute)
+  .route('/', fileRoutes)
+  .route('/{id}', deleteRoute)
+
+export default app;

+ 119 - 0
src/server/api/files/multipart-complete/post.ts

@@ -0,0 +1,119 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '@/server/modules/files/file.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.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.string().openapi({
+    description: '文件ID',
+    example: 'file_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'
+  })
+});
+
+// 创建完成分片上传路由定义
+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 } }
+    }
+  }
+});
+
+// 初始化FileService
+const fileService = new FileService(AppDataSource);
+
+// 创建路由实例并实现处理逻辑
+const app = new OpenAPIHono<AuthContext>().openapi(completeMultipartUploadRoute, async (c) => {
+  try {
+    const data = await c.req.json();
+    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;

+ 109 - 0
src/server/api/files/multipart-policy/post.ts

@@ -0,0 +1,109 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '@/server/modules/files/file.service';
+import { MinioService } from '@/server/modules/files/minio.service';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.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'
+  })
+});
+
+// 创建分片上传策略路由定义
+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 fileService = new FileService(AppDataSource);
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(createMultipartUploadPolicyRoute, async (c) => {
+  try {
+    const data = await c.req.json();
+    // 计算分片数量
+    const partCount = Math.ceil(data.totalSize / data.partSize);
+    const result = await fileService.createMultipartUploadPolicy(data, 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;

+ 92 - 0
src/server/api/files/upload-policy/post.ts

@@ -0,0 +1,92 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { FileService } from '@/server/modules/files/file.service';
+import { FileSchema, CreateFileDto, File } from '@/server/modules/files/file.entity';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 创建文件上传策略路由
+const createUploadPolicyRoute = createRoute({
+  method: 'post',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateFileDto }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '生成文件上传策略成功',
+      content: {
+        'application/json': {
+          schema: 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()
+            })
+          })
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建文件服务实例
+const fileService = new FileService(AppDataSource);
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(createUploadPolicyRoute, async (c) => {
+  try {
+    const data = await c.req.json();
+    const user = c.get('user');
+    
+    // 添加用户ID到文件数据
+    const fileData = {
+      ...data,
+      uploadUserId: user.id,
+      uploadTime: new Date()
+    };
+    const result = await fileService.createFile(fileData);
+    // 手动转换日期类型并处理可选字段
+    const formattedFile = {
+      ...result.file,
+      description: result.file.description ?? null,
+      type: result.file.type ?? null,
+      size: result.file.size ?? null,
+      lastUpdated: result.file.lastUpdated ? result.file.lastUpdated.toISOString() : null,
+      createdAt: result.file.createdAt.toISOString(),
+      uploadTime: result.file.uploadTime.toISOString()
+    };
+    
+    const typedResult = {
+      file: formattedFile,
+      uploadPolicy: result.uploadPolicy
+    };
+    return c.json(typedResult, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '生成上传策略失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;