فهرست منبع

✨ feat(file): 添加从URL下载文件并保存到MinIO的功能

- 实现FileService.downloadAndSaveFromUrl方法,支持从URL下载文件并保存到MinIO
- 添加自动处理文件名、MIME类型推断和存储路径生成的逻辑
- 支持自定义文件名、存储路径和下载超时设置

✨ feat(auth): 使用新的文件下载服务优化头像下载功能

- 重构MiniAuthService,使用FileService下载和保存用户头像
- 移除冗余的头像下载和保存代码,简化认证服务
- 添加错误处理和日志记录,提高可靠性

📝 docs(file): 添加downloadAndSaveFromUrl方法的使用文档

- 详细说明新方法的功能、参数和使用场景
- 提供基础调用和高级配置的代码示例
- 说明返回值结构和错误处理方式
yourname 4 ماه پیش
والد
کامیت
68ebdcc692
3فایلهای تغییر یافته به همراه330 افزوده شده و 36 حذف شده
  1. 152 0
      .roo/commands/download-file-from-url.md
  2. 15 36
      src/server/modules/auth/mini-auth.service.ts
  3. 163 0
      src/server/modules/files/file.service.ts

+ 152 - 0
.roo/commands/download-file-from-url.md

@@ -0,0 +1,152 @@
+---
+description: "从任意URL下载文件并保存到MinIO,同时创建数据库记录"
+---
+
+# FileService.downloadAndSaveFromUrl 使用指令
+
+## 功能概述
+`downloadAndSaveFromUrl` 是 FileService 中新增的统一方法,用于从任意URL下载文件并保存到MinIO,同时创建数据库记录。
+
+## 基本使用
+
+### 导入服务
+```typescript
+import { FileService } from '@/server/modules/files/file.service';
+import { DataSource } from 'typeorm';
+
+// 在服务中注入
+const fileService = new FileService(dataSource);
+```
+
+### 基础调用
+```typescript
+const result = await fileService.downloadAndSaveFromUrl(
+  'https://example.com/image.jpg',
+  {
+    uploadUserId: 123, // 必需:上传用户ID
+  }
+);
+// 返回: { file: File实体, url: 文件访问URL }
+```
+
+## 参数说明
+
+### 必填参数
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| url | string | 要下载的文件URL |
+| fileData.uploadUserId | number | 上传用户的ID |
+
+### 可选参数
+| 参数 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| fileData.mimeType | string | 自动推断 | 文件MIME类型 |
+| fileData.customFileName | string | 自动获取 | 自定义文件名 |
+| fileData.customPath | string | 自动生成 | 自定义存储路径 |
+| options.timeout | number | 30000 | 下载超时时间(ms) |
+| options.retries | number | 0 | 重试次数 |
+
+## 使用场景示例
+
+### 1. 下载用户头像
+```typescript
+const avatarFile = await fileService.downloadAndSaveFromUrl(
+  'https://thirdwx.qlogo.cn/mmopen/vi_32/xxx/132',
+  {
+    uploadUserId: userId,
+    customPath: 'avatars/',
+    mimeType: 'image/jpeg'
+  }
+);
+```
+
+### 2. 下载文档附件
+```typescript
+const docFile = await fileService.downloadAndSaveFromUrl(
+  'https://example.com/report.pdf',
+  {
+    uploadUserId: userId,
+    customFileName: 'monthly-report.pdf',
+    customPath: 'documents/reports/'
+  }
+);
+```
+
+### 3. 批量下载图片
+```typescript
+const imageUrls = ['url1.jpg', 'url2.png', 'url3.gif'];
+const results = await Promise.all(
+  imageUrls.map(url => 
+    fileService.downloadAndSaveFromUrl(url, { uploadUserId: userId })
+  )
+);
+```
+
+## 错误处理
+
+### 异常类型
+- `从URL下载文件失败`: 网络或服务器错误
+- `文件保存失败`: MinIO存储或数据库错误
+
+### 使用示例
+```typescript
+try {
+  const result = await fileService.downloadAndSaveFromUrl(url, { uploadUserId });
+  return result.file.id;
+} catch (error) {
+  console.error('下载失败:', error.message);
+  return null; // 或抛出异常
+}
+```
+
+## 高级配置
+
+### 自定义文件名和路径
+```typescript
+await fileService.downloadAndSaveFromUrl(
+  'https://cdn.example.com/avatar.png',
+  {
+    uploadUserId: 1001,
+    customFileName: 'user-1001-avatar.png',
+    customPath: 'users/1001/profile/'
+  }
+);
+```
+
+### 设置超时时间
+```typescript
+await fileService.downloadAndSaveFromUrl(
+  'https://large-file.example.com/video.mp4',
+  {
+    uploadUserId: userId
+  },
+  {
+    timeout: 60000, // 60秒超时
+    retries: 2      // 重试2次
+  }
+);
+```
+
+## 返回值结构
+```typescript
+{
+  file: {
+    id: number,
+    name: string,
+    path: string,
+    size: number,
+    mimeType: string,
+    url: string,
+    // ...其他File实体字段
+  },
+  url: string // MinIO访问URL
+}
+```
+
+## 注意事项
+
+1. **网络要求**: 确保服务器能够访问目标URL
+2. **文件大小**: 大文件下载可能超时,可调整timeout参数
+3. **文件名冲突**: 系统自动添加UUID避免冲突
+4. **MIME类型**: 优先使用提供的mimeType,否则自动推断
+5. **错误日志**: 所有错误都会记录详细日志便于调试

+ 15 - 36
src/server/modules/auth/mini-auth.service.ts

@@ -1,17 +1,18 @@
 import { DataSource, Repository } from 'typeorm';
 import { UserEntity } from '@/server/modules/users/user.entity';
 import { File } from '@/server/modules/files/file.entity';
+import { FileService } from '@/server/modules/files/file.service';
 import jwt from 'jsonwebtoken';
 import axios from 'axios';
 import { v4 as uuidv4 } from 'uuid';
 
 export class MiniAuthService {
   private userRepository: Repository<UserEntity>;
-  private fileRepository: Repository<File>;
+  private fileService: FileService;
   
   constructor(private dataSource: DataSource) {
     this.userRepository = dataSource.getRepository(UserEntity);
-    this.fileRepository = dataSource.getRepository(File);
+    this.fileService = new FileService(dataSource);
   }
 
   async miniLogin(code: string): Promise<{ token: string; user: UserEntity; isNewUser: boolean }> {
@@ -50,7 +51,7 @@ export class MiniAuthService {
     // 处理头像:如果用户没有头像且提供了小程序头像URL,则下载保存
     if (profile.avatarUrl && !user.avatarFileId) {
       try {
-        const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl);
+        const avatarFileId = await this.downloadAndSaveAvatar(profile.avatarUrl, userId);
         if (avatarFileId) {
           user.avatarFileId = avatarFileId;
         }
@@ -108,47 +109,25 @@ export class MiniAuthService {
     return await this.userRepository.save(user);
   }
 
-  private async downloadAndSaveAvatar(avatarUrl: string): Promise<number | null> {
+  private async downloadAndSaveAvatar(avatarUrl: string, userId: number): Promise<number | null> {
     try {
-      // 从URL下载头像
-      const response = await axios.get(avatarUrl, { 
-        responseType: 'arraybuffer',
-        timeout: 10000
-      });
+      const result = await this.fileService.downloadAndSaveFromUrl(
+        avatarUrl,
+        {
+          uploadUserId: userId,
+          customPath: `avatars/`,
+          mimeType: 'image/jpeg'
+        },
+        { timeout: 10000 }
+      );
       
-      const buffer = Buffer.from(response.data);
-      
-      // 生成文件名
-      const fileName = `avatar_${uuidv4()}.jpg`;
-      const filePath = `avatars/${fileName}`;
-      
-      // 上传到文件服务(这里模拟上传到MinIO)
-      // 实际项目中需要集成MinIO或其他文件存储服务
-      const fileRecord = this.fileRepository.create({
-        originalName: fileName,
-        fileName: fileName,
-        filePath: filePath,
-        fileSize: buffer.length,
-        mimeType: 'image/jpeg',
-        fileType: 'image',
-        extension: 'jpg',
-        md5: this.calculateMD5(buffer),
-        url: `${process.env.MINIO_ENDPOINT || ''}/avatars/${fileName}`
-      });
-      
-      const savedFile = await this.fileRepository.save(fileRecord);
-      return savedFile.id;
+      return result.file.id;
     } catch (error) {
       console.error('下载保存头像失败:', error);
       return null;
     }
   }
 
-  private calculateMD5(buffer: Buffer): string {
-    // 简化的MD5计算,实际项目中应使用crypto模块
-    return require('crypto').createHash('md5').update(buffer).digest('hex');
-  }
-
   private generateToken(user: UserEntity): string {
     const payload = {
       id: user.id,

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

@@ -356,4 +356,167 @@ export class FileService extends GenericCrudService<File> {
       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';
+  }
 }