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"
|
KAVA_RPC_URL="https://evm.kava.io"
|
||||||
BSC_RPC_URL="https://bsc-dataseed.binance.org"
|
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",
|
"class-validator": "^0.14.0",
|
||||||
"ethers": "^6.9.0",
|
"ethers": "^6.9.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"minio": "^8.0.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|
@ -64,6 +66,7 @@
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { Controller, Post, Get, Put, Body, Param, UseGuards, Headers } from '@nestjs/common';
|
import {
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
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 { 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 { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||||
|
|
@ -22,7 +27,10 @@ import {
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class UserAccountController {
|
export class UserAccountController {
|
||||||
constructor(private readonly userService: UserApplicationService) {}
|
constructor(
|
||||||
|
private readonly userService: UserApplicationService,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('auto-create')
|
@Post('auto-create')
|
||||||
|
|
@ -189,4 +197,59 @@ export class UserAccountController {
|
||||||
);
|
);
|
||||||
return { message: '已标记为已备份' };
|
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 { SmsService } from './external/sms/sms.service';
|
||||||
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
||||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
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';
|
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
|
|
@ -40,6 +41,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
MpcWalletService,
|
MpcWalletService,
|
||||||
|
StorageService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -57,6 +59,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
MpcWalletService,
|
MpcWalletService,
|
||||||
|
StorageService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue