From 550a3dba063b77eea5156fa9a2022ab713fac14b Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 7 Dec 2025 21:38:35 -0800 Subject: [PATCH] feat(identity): add avatar upload with MinIO storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StorageService for MinIO object storage operations - Add upload-avatar API endpoint with file validation - Support jpg, png, gif, webp formats (max 5MB) - Auto-update user profile after avatar upload 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../services/identity-service/.env.example | 19 ++ .../services/identity-service/package.json | 3 + .../controllers/user-account.controller.ts | 69 +++++- .../infrastructure/external/storage/index.ts | 2 + .../external/storage/storage.module.ts | 10 + .../external/storage/storage.service.ts | 197 ++++++++++++++++++ .../infrastructure/infrastructure.module.ts | 3 + 7 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 backend/services/identity-service/src/infrastructure/external/storage/index.ts create mode 100644 backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts create mode 100644 backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts diff --git a/backend/services/identity-service/.env.example b/backend/services/identity-service/.env.example index c8069361..3b015e69 100644 --- a/backend/services/identity-service/.env.example +++ b/backend/services/identity-service/.env.example @@ -105,3 +105,22 @@ SERVICE_JWT_SECRET="your-service-jwt-secret-change-in-production" # ============================================================================= KAVA_RPC_URL="https://evm.kava.io" BSC_RPC_URL="https://bsc-dataseed.binance.org" + +# ============================================================================= +# MinIO Object Storage Configuration +# ============================================================================= +# MinIO endpoint (internal Docker: http://rwa-minio:9000) +MINIO_ENDPOINT="localhost" +MINIO_PORT=9000 +MINIO_USE_SSL=false + +# MinIO credentials (must match minio docker-compose config) +MINIO_ACCESS_KEY="admin" +MINIO_SECRET_KEY="minio_secret_password" + +# Bucket for user avatars +MINIO_BUCKET_AVATARS="avatars" + +# Public URL for accessing files (via Nginx or direct) +# For production, use CDN URL: https://cdn.szaiai.com +MINIO_PUBLIC_URL="http://localhost:9000" diff --git a/backend/services/identity-service/package.json b/backend/services/identity-service/package.json index 109edfbb..c7bcafc0 100644 --- a/backend/services/identity-service/package.json +++ b/backend/services/identity-service/package.json @@ -46,6 +46,8 @@ "class-validator": "^0.14.0", "ethers": "^6.9.0", "ioredis": "^5.3.2", + "minio": "^8.0.1", + "multer": "^1.4.5-lts.1", "kafkajs": "^2.2.4", "jsonwebtoken": "^9.0.0", "passport-jwt": "^4.0.1", @@ -64,6 +66,7 @@ "@types/passport-jwt": "^4.0.0", "@types/supertest": "^6.0.0", "@types/uuid": "^9.0.0", + "@types/multer": "^1.4.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index 106d3365..8e7748d3 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -1,6 +1,11 @@ -import { Controller, Post, Get, Put, Body, Param, UseGuards, Headers } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { + Controller, Post, Get, Put, Body, Param, UseGuards, Headers, + UseInterceptors, UploadedFile, BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger'; import { UserApplicationService } from '@/application/services/user-application.service'; +import { StorageService } from '@/infrastructure/external/storage/storage.service'; import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard'; import { AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, @@ -22,7 +27,10 @@ import { @Controller('user') @UseGuards(JwtAuthGuard) export class UserAccountController { - constructor(private readonly userService: UserApplicationService) {} + constructor( + private readonly userService: UserApplicationService, + private readonly storageService: StorageService, + ) {} @Public() @Post('auto-create') @@ -189,4 +197,59 @@ export class UserAccountController { ); return { message: '已标记为已备份' }; } + + @Post('upload-avatar') + @ApiBearerAuth() + @ApiOperation({ summary: '上传用户头像' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)', + }, + }, + }, + }) + @ApiResponse({ status: 200, description: '上传成功,返回头像URL' }) + @UseInterceptors(FileInterceptor('file')) + async uploadAvatar( + @CurrentUser() user: CurrentUserData, + @UploadedFile() file: Express.Multer.File, + ) { + // 验证文件是否存在 + if (!file) { + throw new BadRequestException('请选择要上传的图片'); + } + + // 验证文件类型 + if (!this.storageService.isValidImageType(file.mimetype)) { + throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp'); + } + + // 验证文件大小 + if (file.size > this.storageService.maxAvatarSize) { + throw new BadRequestException('图片大小不能超过 5MB'); + } + + // 上传文件 + const result = await this.storageService.uploadAvatar( + user.userId, + file.buffer, + file.mimetype, + ); + + // 更新用户头像URL + await this.userService.updateProfile( + new UpdateProfileCommand(user.userId, undefined, result.url), + ); + + return { + message: '上传成功', + avatarUrl: result.url, + }; + } } diff --git a/backend/services/identity-service/src/infrastructure/external/storage/index.ts b/backend/services/identity-service/src/infrastructure/external/storage/index.ts new file mode 100644 index 00000000..b290d1fb --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/storage/index.ts @@ -0,0 +1,2 @@ +export * from './storage.service'; +export * from './storage.module'; diff --git a/backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts b/backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts new file mode 100644 index 00000000..34d142dd --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StorageService } from './storage.service'; + +@Module({ + imports: [ConfigModule], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts b/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts new file mode 100644 index 00000000..af492d3a --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as Minio from 'minio'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * 文件上传结果 + */ +export interface UploadResult { + /** 文件唯一标识 */ + key: string; + /** 公开访问URL */ + url: string; + /** 文件大小(字节) */ + size: number; + /** MIME类型 */ + contentType: string; +} + +/** + * MinIO 对象存储服务 + * + * 用于处理用户头像等文件的上传和管理 + */ +@Injectable() +export class StorageService implements OnModuleInit { + private readonly logger = new Logger(StorageService.name); + private client: Minio.Client; + private bucketAvatars: string; + private publicUrl: string; + + constructor(private readonly configService: ConfigService) { + const endpoint = this.configService.get('MINIO_ENDPOINT', 'localhost'); + const port = this.configService.get('MINIO_PORT', 9000); + const useSSL = this.configService.get('MINIO_USE_SSL', 'false') === 'true'; + const accessKey = this.configService.get('MINIO_ACCESS_KEY', 'admin'); + const secretKey = this.configService.get('MINIO_SECRET_KEY', 'minio_secret_password'); + + this.bucketAvatars = this.configService.get('MINIO_BUCKET_AVATARS', 'avatars'); + this.publicUrl = this.configService.get('MINIO_PUBLIC_URL', 'http://localhost:9000'); + + this.client = new Minio.Client({ + endPoint: endpoint, + port: port, + useSSL: useSSL, + accessKey: accessKey, + secretKey: secretKey, + }); + + this.logger.log(`MinIO client initialized: ${endpoint}:${port}`); + } + + async onModuleInit() { + // 检查并创建头像存储桶 + await this.ensureBucket(this.bucketAvatars); + } + + /** + * 确保存储桶存在 + */ + private async ensureBucket(bucketName: string): Promise { + try { + const exists = await this.client.bucketExists(bucketName); + if (!exists) { + await this.client.makeBucket(bucketName); + this.logger.log(`Bucket created: ${bucketName}`); + + // 设置公开读取策略 + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }; + await this.client.setBucketPolicy(bucketName, JSON.stringify(policy)); + this.logger.log(`Bucket policy set for: ${bucketName}`); + } else { + this.logger.log(`Bucket exists: ${bucketName}`); + } + } catch (error) { + this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`); + // 不抛出异常,允许服务启动(MinIO可能暂时不可用) + } + } + + /** + * 上传用户头像 + * + * @param userId 用户ID + * @param buffer 文件内容 + * @param contentType MIME类型 (image/jpeg, image/png, etc.) + * @returns 上传结果 + */ + async uploadAvatar( + userId: string, + buffer: Buffer, + contentType: string, + ): Promise { + // 生成唯一文件名: avatars/userId/uuid.ext + const extension = this.getExtensionFromContentType(contentType); + const key = `${userId}/${uuidv4()}${extension}`; + + try { + await this.client.putObject( + this.bucketAvatars, + key, + buffer, + buffer.length, + { 'Content-Type': contentType }, + ); + + const url = `${this.publicUrl}/${this.bucketAvatars}/${key}`; + + this.logger.log(`Avatar uploaded: ${key} (${buffer.length} bytes)`); + + return { + key, + url, + size: buffer.length, + contentType, + }; + } catch (error) { + this.logger.error(`Failed to upload avatar: ${error.message}`); + throw new Error(`文件上传失败: ${error.message}`); + } + } + + /** + * 删除用户头像 + * + * @param key 文件key + */ + async deleteAvatar(key: string): Promise { + try { + await this.client.removeObject(this.bucketAvatars, key); + this.logger.log(`Avatar deleted: ${key}`); + } catch (error) { + this.logger.error(`Failed to delete avatar: ${error.message}`); + // 删除失败不抛出异常 + } + } + + /** + * 从URL中提取文件key + */ + extractKeyFromUrl(url: string): string | null { + if (!url || url.startsWith(' p); + if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) { + return pathParts.slice(1).join('/'); + } + } catch { + // 不是有效URL + } + return null; + } + + /** + * 根据Content-Type获取文件扩展名 + */ + private getExtensionFromContentType(contentType: string): string { + const map: Record = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + }; + return map[contentType] || '.jpg'; + } + + /** + * 验证图片类型 + */ + isValidImageType(contentType: string): boolean { + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + return validTypes.includes(contentType); + } + + /** + * 最大头像文件大小 (5MB) + */ + get maxAvatarSize(): number { + return 5 * 1024 * 1024; + } +} diff --git a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts index 2b207701..2ee0448d 100644 --- a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts @@ -12,6 +12,7 @@ import { BlockchainEventConsumerService } from './kafka/blockchain-event-consume import { SmsService } from './external/sms/sms.service'; import { BlockchainClientService } from './external/blockchain/blockchain-client.service'; import { MpcClientService, MpcWalletService } from './external/mpc'; +import { StorageService } from './external/storage/storage.service'; import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface'; @Global() @@ -40,6 +41,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re BlockchainClientService, MpcClientService, MpcWalletService, + StorageService, ], exports: [ PrismaService, @@ -57,6 +59,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re BlockchainClientService, MpcClientService, MpcWalletService, + StorageService, ], }) export class InfrastructureModule {}