Ver código fonte

✨ feat(file): add file download and save from URL functionality

- implement downloadAndSaveFromUrl method to download files from URL and save to MinIO
- add extractFileNameFromUrl helper method to parse filename from URL
- add axios dependency for HTTP requests
- add timeout and retry options for download requests

✨ feat(wechat): enhance wechat authentication with avatar handling

- integrate file download service to save wechat avatars to MinIO
- add avatar download and update during first login
- add avatar update during subsequent logins
- handle avatar download failures gracefully without breaking auth flow

📝 docs(logger): add info level logging

- add info level logger for general information messages
- use info logging for file download and save operations
yourname 6 meses atrás
pai
commit
b9a47d42fe

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

@@ -5,6 +5,7 @@ import { MinioService } from './minio.service';
 // import { AppError } from '@/server/utils/errorHandler';
 import { v4 as uuidv4 } from 'uuid';
 import { logger } from '@/server/utils/logger';
+import axios from 'axios';
 
 export class FileService extends GenericCrudService<File> {
   private readonly minioService: MinioService;
@@ -112,6 +113,112 @@ export class FileService extends GenericCrudService<File> {
     };
   }
 
+  /**
+   * 从URL下载文件并保存到MinIO,同时创建数据库记录
+   */
+  async downloadAndSaveFromUrl(
+    url: string,
+    fileData: {
+      uploadUserId: number;
+      mimeType?: string;
+      customFileName?: string;
+      customPath?: string;
+    },
+    options: {
+      timeout?: number;
+      retries?: number;
+    } = {}
+  ): Promise<{ file: File; url: string }> {
+    try {
+      const { uploadUserId, mimeType, customFileName, customPath } = fileData;
+      const { timeout = 30000, retries = 0 } = options;
+
+      logger.info('Downloading file from URL:', { url, uploadUserId });
+
+      // 下载文件
+      const response = await axios.get(url, {
+        responseType: 'arraybuffer',
+        timeout,
+        validateStatus: (status) => status === 200
+      });
+
+      if (!response.data) {
+        throw new Error('下载的文件内容为空');
+      }
+
+      // 获取文件名和MIME类型
+      const originalFileName = customFileName || this.extractFileNameFromUrl(url);
+      const detectedMimeType = mimeType || response.headers['content-type'] || 'application/octet-stream';
+      const fileSize = Buffer.byteLength(response.data);
+
+      // 生成唯一文件存储路径
+      const fileKey = customPath
+        ? `${customPath}${uploadUserId}/${uuidv4()}-${originalFileName}`
+        : `${uploadUserId}/${uuidv4()}-${originalFileName}`;
+
+      // 上传到MinIO
+      await this.minioService.createObject(
+        this.minioService.bucketName,
+        fileKey,
+        Buffer.from(response.data),
+        detectedMimeType
+      );
+
+      // 创建文件记录
+      const fileRecordData: Partial<File> = {
+        name: originalFileName,
+        path: fileKey,
+        size: fileSize,
+        type: detectedMimeType,
+        uploadUserId,
+        uploadTime: new Date(),
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+
+      const savedFile = await this.create(fileRecordData as File);
+
+      // 生成访问URL
+      const fileUrl = this.minioService.getFileUrl(this.minioService.bucketName, fileKey);
+
+      logger.info('File downloaded and saved successfully:', {
+        fileId: savedFile.id,
+        originalUrl: url,
+        fileSize,
+        mimeType: detectedMimeType
+      });
+
+      return {
+        file: savedFile,
+        url: fileUrl
+      };
+    } catch (error) {
+      logger.error('Failed to download and save file from URL:', error);
+      throw new Error(`从URL下载文件失败: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+
+  /**
+   * 从URL中提取文件名
+   */
+  private extractFileNameFromUrl(url: string): string {
+    try {
+      const urlObj = new URL(url);
+      const pathname = urlObj.pathname;
+      const fileName = pathname.substring(pathname.lastIndexOf('/') + 1);
+      
+      // 如果没有文件名或文件名无效,使用默认名称
+      if (!fileName || fileName === '/' || fileName.includes('?')) {
+        return `downloaded-file-${Date.now()}`;
+      }
+      
+      // 移除查询参数
+      return fileName.split('?')[0];
+    } catch {
+      return `downloaded-file-${Date.now()}`;
+    }
+  }
+
   /**
    * 创建多部分上传策略
    */

+ 51 - 4
src/server/modules/wechat/wechat-auth.service.ts

@@ -1,6 +1,7 @@
 import axios from 'axios';
 import { UserService } from '../users/user.service';
 import { AuthService } from '../auth/auth.service';
+import { FileService } from '../files/file.service';
 import { UserEntity as User } from '../users/user.entity';
 import debug from 'debug';
 
@@ -15,7 +16,8 @@ export class WechatAuthService {
 
   constructor(
     private readonly userService: UserService,
-    private readonly authService: AuthService
+    private readonly authService: AuthService,
+    private readonly fileService: FileService
   ) {}
 
   // 获取授权URL
@@ -56,7 +58,7 @@ export class WechatAuthService {
         // 3. 获取用户信息(首次登录)
         const userInfo = await this.getUserInfo(access_token, openid);
         
-        // 4. 创建用户
+        // 4. 创建用户
         user = await this.userService.createUser({
           username: `wx_${openid.substring(0, 8)}`,
           password: Math.random().toString(36).substring(2),
@@ -70,6 +72,31 @@ export class WechatAuthService {
           wechatCountry: userInfo.country,
           nickname: userInfo.nickname
         });
+
+        // 5. 下载微信头像并保存到MinIO
+        if (userInfo.headimgurl) {
+          try {
+            const avatarResult = await this.fileService.downloadAndSaveFromUrl(
+              userInfo.headimgurl,
+              {
+                uploadUserId: user.id, // 使用新创建的用户ID
+                customPath: 'avatars/wechat/',
+                mimeType: 'image/jpeg'
+              }
+            );
+            
+            // 更新用户的 avatarFileId
+            await this.userService.updateUser(user.id, {
+              avatarFileId: avatarResult.file.id
+            });
+            
+            // 更新返回的用户对象
+            user.avatarFileId = avatarResult.file.id;
+          } catch (error) {
+            logger.error('下载微信头像失败:', error);
+            // 头像下载失败不影响主要流程
+          }
+        }
       }
 
       // 5. 生成JWT token
@@ -102,7 +129,26 @@ export class WechatAuthService {
       // 3. 获取用户信息
       const userInfo = await this.getUserInfo(tokenData.access_token, openid);
 
-      // 4. 更新用户信息
+      // 4. 下载微信头像并保存到MinIO
+      let avatarFileId: number | null = null;
+      if (userInfo.headimgurl) {
+        try {
+          const avatarResult = await this.fileService.downloadAndSaveFromUrl(
+            userInfo.headimgurl,
+            {
+              uploadUserId: userId,
+              customPath: 'avatars/wechat/',
+              mimeType: 'image/jpeg'
+            }
+          );
+          avatarFileId = avatarResult.file.id;
+        } catch (error) {
+          logger.error('下载微信头像失败:', error);
+          // 头像下载失败不影响主要流程,继续更新用户
+        }
+      }
+
+      // 5. 更新用户信息
       const user = await this.userService.updateUser(userId, {
         wechatOpenid: openid,
         wechatUnionid: unionid,
@@ -111,7 +157,8 @@ export class WechatAuthService {
         wechatSex: userInfo.sex,
         wechatProvince: userInfo.province,
         wechatCity: userInfo.city,
-        wechatCountry: userInfo.country
+        wechatCountry: userInfo.country,
+        avatarFileId: avatarFileId
       });
 
       if (!user) {

+ 1 - 0
src/server/utils/logger.ts

@@ -1,6 +1,7 @@
 import debug from 'debug';
 
 export const logger = {
+  info: debug('backend:info'),
   error: debug('backend:error'),
   api: debug('backend:api'),
   db: debug('backend:db'),