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:
parent
6bf23fc8d3
commit
550a3dba06
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
backend/services/identity-service/src/infrastructure/external/storage/index.ts
vendored
Normal file
2
backend/services/identity-service/src/infrastructure/external/storage/index.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './storage.service';
|
||||
export * from './storage.module';
|
||||
10
backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts
vendored
Normal file
10
backend/services/identity-service/src/infrastructure/external/storage/storage.module.ts
vendored
Normal 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 {}
|
||||
197
backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts
vendored
Normal file
197
backend/services/identity-service/src/infrastructure/external/storage/storage.service.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue