feat(identity): add avatar upload with MinIO storage

- 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-07 21:38:35 -08:00
parent 6bf23fc8d3
commit 550a3dba06
7 changed files with 300 additions and 3 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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,
};
}
}

View File

@ -0,0 +1,2 @@
export * from './storage.service';
export * from './storage.module';

View File

@ -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 {}

View File

@ -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<string>('MINIO_ENDPOINT', 'localhost');
const port = this.configService.get<number>('MINIO_PORT', 9000);
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
this.bucketAvatars = this.configService.get<string>('MINIO_BUCKET_AVATARS', 'avatars');
this.publicUrl = this.configService.get<string>('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<void> {
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<UploadResult> {
// 生成唯一文件名: 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<void> {
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('<svg')) {
return null; // SVG内容不是URL
}
try {
const urlObj = new URL(url);
// URL格式: http://host/bucket/key
const pathParts = urlObj.pathname.split('/').filter(p => 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<string, string> = {
'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;
}
}

View File

@ -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 {}