Browse Source

✨ feat(files): add file download functionality

- add new download API endpoint to generate presigned download URL with Content-Disposition header
- implement getFileDownloadUrl method in FileService to handle download URL generation
- add getPresignedFileDownloadUrl method in MinioService to create URLs with proper filename encoding
- update client-side download handler to use new API and properly handle filenames
- register download route in files API router

🐛 fix(files): improve file download reliability

- replace direct URL generation with dedicated download API call
- add error handling for failed download URL requests
- ensure proper filename handling using server-provided filename
- add Content-Disposition header to force file download with correct filename
yourname 4 months ago
parent
commit
c525f62495

+ 17 - 4
src/client/admin/pages/Files.tsx

@@ -48,13 +48,26 @@ export const FilesPage: React.FC = () => {
     }
     }
   };
   };
 
 
+  // 获取文件下载URL
+  const getFileDownloadUrl = async (fileId: number) => {
+    try {
+      const response = await fileClient[':id']['download'].$get({ param: { id: fileId } });
+      if (!response.ok) throw new Error('获取文件下载URL失败');
+      const data = await response.json();
+      return data;
+    } catch (error) {
+      message.error('获取文件下载URL失败');
+      return null;
+    }
+  };
+
   // 处理文件下载
   // 处理文件下载
   const handleDownload = async (record: FileItem) => {
   const handleDownload = async (record: FileItem) => {
-    const url = await getFileUrl(record.id);
-    if (url) {
+    const result = await getFileDownloadUrl(record.id);
+    if (result?.url) {
       const a = document.createElement('a');
       const a = document.createElement('a');
-      a.href = url;
-      a.download = record.name;
+      a.href = result.url;
+      a.download = result.filename || record.name;
       document.body.appendChild(a);
       document.body.appendChild(a);
       a.click();
       a.click();
       document.body.removeChild(a);
       document.body.removeChild(a);

+ 67 - 0
src/server/api/files/[id]/download.ts

@@ -0,0 +1,67 @@
+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';
+
+// 获取文件下载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 fileService = new FileService(AppDataSource);
+
+// 创建路由实例
+const app = new OpenAPIHono<AuthContext>().openapi(downloadFileRoute, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    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;

+ 8 - 6
src/server/api/files/index.ts

@@ -4,6 +4,7 @@ import multipartPolicyRoute from './multipart-policy/post';
 import completeMultipartRoute from './multipart-complete/post';
 import completeMultipartRoute from './multipart-complete/post';
 import getUrlRoute from './[id]/get-url';
 import getUrlRoute from './[id]/get-url';
 import deleteRoute from './[id]/delete';
 import deleteRoute from './[id]/delete';
+import downloadRoute from './[id]/download';
 import { AuthContext } from '@/server/types/context';
 import { AuthContext } from '@/server/types/context';
 
 
 import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
 import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
@@ -25,11 +26,12 @@ const fileRoutes = createCrudRoutes({
 
 
 // 创建路由实例并聚合所有子路由
 // 创建路由实例并聚合所有子路由
 const app = new OpenAPIHono<AuthContext>()
 const app = new OpenAPIHono<AuthContext>()
-  .route('/upload-policy', uploadPolicyRoute)
-  .route('/multipart-policy', multipartPolicyRoute)
-  .route('/multipart-complete', completeMultipartRoute)
-  .route('/', getUrlRoute)
-  .route('/', deleteRoute)
-  .route('/', fileRoutes)
+.route('/upload-policy', uploadPolicyRoute)
+.route('/multipart-policy', multipartPolicyRoute)
+.route('/multipart-complete', completeMultipartRoute)
+.route('/', getUrlRoute)
+.route('/', deleteRoute)
+.route('/', downloadRoute)
+.route('/', fileRoutes)
 
 
 export default app;
 export default app;

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

@@ -91,6 +91,27 @@ export class FileService extends GenericCrudService<File> {
     return this.minioService.getPresignedFileUrl(this.minioService.bucketName, file.path);
     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
+    };
+  }
+
   /**
   /**
    * 创建多部分上传策略
    * 创建多部分上传策略
    */
    */

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

@@ -107,6 +107,26 @@ export class MinioService {
     }
     }
   }
   }
 
 
+  // 生成预签名文件下载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) {
   async createMultipartUpload(bucketName: string, objectName: string) {
     try {
     try {