feat: 实现手机号+密码登录和账号恢复功能

## 后端更改

### 新增功能
- 添加手机号+密码登录 API (`POST /user/login-with-password`)
  - 新增 LoginWithPasswordDto 验证手机号格式和密码长度
  - 实现 loginWithPassword 服务方法,使用 bcrypt 验证密码
  - 返回 JWT tokens(accessToken + refreshToken)

### 代码优化
- 修复 phone.validator.ts 中的 TypeScript 类型错误(Object -> object)

## 前端更改

### 新增功能
- 实现手机号+密码登录页面 (phone_login_page.dart)
  - 完整的表单验证(手机号格式、密码长度)
  - 集成 AccountService.loginWithPassword API
  - 登录成功后自动更新认证状态并跳转主页

### 账号服务优化
- 在 AccountService 中添加 loginWithPassword 方法
  - 调用后端 login-with-password API
  - 自动保存认证数据(tokens、用户信息)
  - 使用 _savePhoneAuthData 统一保存逻辑

### UI 文案更新
- 向导页文案修改:"创建账号" → "注册账号"
  - 更新标题、副标题和按钮文本
  - 添加"恢复账号"按钮,跳转到手机号密码登录页

## 已验证功能

 前端代码编译通过(0 errors, 仅有非关键警告)
 后端代码编译通过(0 errors, 仅有非关键警告)
 30天登录状态保持(JWT refresh token 已配置为30天)
 自动路由逻辑(有登录状态直接进入主页)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-20 20:35:44 -08:00
parent 65f3e75f59
commit b4c4239593
128 changed files with 11985 additions and 10066 deletions

View File

@ -1,12 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UserAccountController } from './controllers/user-account.controller'; import { UserAccountController } from './controllers/user-account.controller';
import { AuthController } from './controllers/auth.controller'; import { AuthController } from './controllers/auth.controller';
import { ReferralsController } from './controllers/referrals.controller'; import { ReferralsController } from './controllers/referrals.controller';
import { TotpController } from './controllers/totp.controller'; import { TotpController } from './controllers/totp.controller';
import { ApplicationModule } from '@/application/application.module'; import { ApplicationModule } from '@/application/application.module';
@Module({ @Module({
imports: [ApplicationModule], imports: [ApplicationModule],
controllers: [UserAccountController, AuthController, ReferralsController, TotpController], controllers: [
}) UserAccountController,
export class ApiModule {} AuthController,
ReferralsController,
TotpController,
],
})
export class ApiModule {}

View File

@ -1,89 +1,102 @@
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; Controller,
import { JwtService } from '@nestjs/jwt'; Post,
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; Body,
import { UserApplicationService } from '@/application/services/user-application.service'; UnauthorizedException,
import { Public } from '@/shared/guards/jwt-auth.guard'; Logger,
import { AutoLoginCommand } from '@/application/commands'; } from '@nestjs/common';
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import * as bcrypt from 'bcrypt'; import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
@ApiTags('Auth') import { UserApplicationService } from '@/application/services/user-application.service';
@Controller('auth') import { Public } from '@/shared/guards/jwt-auth.guard';
export class AuthController { import { AutoLoginCommand } from '@/application/commands';
private readonly logger = new Logger(AuthController.name); import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
import * as bcrypt from 'bcrypt';
constructor(
private readonly userService: UserApplicationService, @ApiTags('Auth')
private readonly jwtService: JwtService, @Controller('auth')
private readonly prisma: PrismaService, export class AuthController {
) {} private readonly logger = new Logger(AuthController.name);
@Public() constructor(
@Post('refresh') private readonly userService: UserApplicationService,
@ApiOperation({ summary: 'Token刷新' }) private readonly jwtService: JwtService,
async refresh(@Body() dto: AutoLoginDto) { private readonly prisma: PrismaService,
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId)); ) {}
}
@Public()
@Public() @Post('refresh')
@Post('login') @ApiOperation({ summary: 'Token刷新' })
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' }) async refresh(@Body() dto: AutoLoginDto) {
@ApiResponse({ status: 200, type: AdminLoginResponseDto }) return this.userService.autoLogin(
@ApiResponse({ status: 401, description: '邮箱或密码错误' }) new AutoLoginCommand(dto.refreshToken, dto.deviceId),
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> { );
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`); }
// 从数据库查找管理员 @Public()
const admin = await this.prisma.adminAccount.findUnique({ @Post('login')
where: { email: dto.email }, @ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
}); @ApiResponse({ status: 200, type: AdminLoginResponseDto })
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
if (!admin) { async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`); this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
throw new UnauthorizedException('邮箱或密码错误');
} // 从数据库查找管理员
const admin = await this.prisma.adminAccount.findUnique({
// 检查账户状态 where: { email: dto.email },
if (admin.status !== 'ACTIVE') { });
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`);
throw new UnauthorizedException('账户已被禁用'); if (!admin) {
} this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
throw new UnauthorizedException('邮箱或密码错误');
// 验证密码 (使用 bcrypt) }
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
// 检查账户状态
if (!isPasswordValid) { if (admin.status !== 'ACTIVE') {
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`); this.logger.warn(
throw new UnauthorizedException('邮箱或密码错误'); `[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`,
} );
throw new UnauthorizedException('账户已被禁用');
// 更新最后登录时间 }
await this.prisma.adminAccount.update({
where: { id: admin.id }, // 验证密码 (使用 bcrypt)
data: { lastLoginAt: new Date() }, const isPasswordValid = await bcrypt.compare(
}); dto.password,
admin.passwordHash,
// 生成 JWT Token );
const payload = {
sub: admin.id.toString(), if (!isPasswordValid) {
email: admin.email, this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
role: admin.role, throw new UnauthorizedException('邮箱或密码错误');
type: 'admin', }
};
// 更新最后登录时间
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' }); await this.prisma.adminAccount.update({
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); where: { id: admin.id },
data: { lastLoginAt: new Date() },
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`); });
return { // 生成 JWT Token
userId: admin.id.toString(), const payload = {
email: admin.email, sub: admin.id.toString(),
nickname: admin.nickname, email: admin.email,
role: admin.role, role: admin.role,
accessToken, type: 'admin',
refreshToken, };
};
} const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
} const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
return {
userId: admin.id.toString(),
email: admin.email,
nickname: admin.nickname,
role: admin.role,
accessToken,
refreshToken,
};
}
}

View File

@ -1,18 +1,18 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '@/shared/decorators/public.decorator'; import { Public } from '@/shared/decorators/public.decorator';
@ApiTags('健康检查') @ApiTags('健康检查')
@Controller() @Controller()
export class HealthController { export class HealthController {
@Public() @Public()
@Get('health') @Get('health')
@ApiOperation({ summary: '健康检查端点' }) @ApiOperation({ summary: '健康检查端点' })
health() { health() {
return { return {
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
service: 'identity-service', service: 'identity-service',
}; };
} }
} }

View File

@ -1,68 +1,104 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import {
import { UserApplicationService } from '@/application/services/user-application.service'; ApiTags,
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard'; ApiOperation,
import { ApiBearerAuth,
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand, ApiResponse,
} from '@/application/commands'; ApiQuery,
import { } from '@nestjs/swagger';
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto, import { UserApplicationService } from '@/application/services/user-application.service';
ReferralLinkResponseDto, ReferralStatsResponseDto, import {
} from '@/api/dto'; JwtAuthGuard,
Public,
@ApiTags('Referrals') CurrentUser,
@Controller() CurrentUserData,
@UseGuards(JwtAuthGuard) } from '@/shared/guards/jwt-auth.guard';
export class ReferralsController { import {
constructor(private readonly userService: UserApplicationService) {} ValidateReferralCodeQuery,
GetReferralStatsQuery,
/** GenerateReferralLinkCommand,
* GET /api/me - + } from '@/application/commands';
*/ import {
@Get('me') GenerateReferralLinkDto,
@ApiBearerAuth() MeResponseDto,
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' }) ReferralValidationResponseDto,
@ApiResponse({ status: 200, type: MeResponseDto }) ReferralLinkResponseDto,
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> { ReferralStatsResponseDto,
return this.userService.getMe(user.userId); } from '@/api/dto';
}
@ApiTags('Referrals')
/** @Controller()
* GET /api/referrals/validate - @UseGuards(JwtAuthGuard)
*/ export class ReferralsController {
@Public() constructor(private readonly userService: UserApplicationService) {}
@Get('referrals/validate')
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' }) /**
@ApiQuery({ name: 'code', description: '推荐码', required: true }) * GET /api/me - +
@ApiResponse({ status: 200, type: ReferralValidationResponseDto }) */
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> { @Get('me')
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code)); @ApiBearerAuth()
} @ApiOperation({
summary: '获取当前登录用户信息',
/** description: '返回用户基本信息、推荐码和推荐链接',
* POST /api/referrals/links - / })
*/ @ApiResponse({ status: 200, type: MeResponseDto })
@Post('referrals/links') async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
@ApiBearerAuth() return this.userService.getMe(user.userId);
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' }) }
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
async generateReferralLink( /**
@CurrentUser() user: CurrentUserData, * GET /api/referrals/validate -
@Body() dto: GenerateReferralLinkDto, */
): Promise<ReferralLinkResponseDto> { @Public()
return this.userService.generateReferralLink( @Get('referrals/validate')
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId), @ApiOperation({
); summary: '校验推荐码',
} description: '创建账号时校验推荐码是否合法',
})
/** @ApiQuery({ name: 'code', description: '推荐码', required: true })
* GET /api/referrals/stats - @ApiResponse({ status: 200, type: ReferralValidationResponseDto })
*/ async validateReferralCode(
@Get('referrals/stats') @Query('code') code: string,
@ApiBearerAuth() ): Promise<ReferralValidationResponseDto> {
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' }) return this.userService.validateReferralCode(
@ApiResponse({ status: 200, type: ReferralStatsResponseDto }) new ValidateReferralCodeQuery(code),
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> { );
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId)); }
}
} /**
* POST /api/referrals/links - /
*/
@Post('referrals/links')
@ApiBearerAuth()
@ApiOperation({
summary: '生成推荐链接',
description: '为当前登录用户生成短链/渠道链接',
})
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
async generateReferralLink(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateReferralLinkDto,
): Promise<ReferralLinkResponseDto> {
return this.userService.generateReferralLink(
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
);
}
/**
* GET /api/referrals/stats -
*/
@Get('referrals/stats')
@ApiBearerAuth()
@ApiOperation({
summary: '查询邀请统计',
description: '查询登录用户的邀请记录和统计数据',
})
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
async getReferralStats(
@CurrentUser() user: CurrentUserData,
): Promise<ReferralStatsResponseDto> {
return this.userService.getReferralStats(
new GetReferralStatsQuery(user.userId),
);
}
}

View File

@ -1,5 +1,10 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
} from '@nestjs/swagger';
import { TotpService } from '@/application/services/totp.service'; import { TotpService } from '@/application/services/totp.service';
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators'; import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
@ -36,21 +41,34 @@ export class TotpController {
constructor(private readonly totpService: TotpService) {} constructor(private readonly totpService: TotpService) {}
@Get('status') @Get('status')
@ApiOperation({ summary: '获取 TOTP 状态', description: '查询当前用户的 TOTP 启用状态' }) @ApiOperation({
summary: '获取 TOTP 状态',
description: '查询当前用户的 TOTP 启用状态',
})
@ApiResponse({ status: 200, type: TotpStatusResponseDto }) @ApiResponse({ status: 200, type: TotpStatusResponseDto })
async getStatus(@CurrentUser() user: CurrentUserPayload): Promise<TotpStatusResponseDto> { async getStatus(
@CurrentUser() user: CurrentUserPayload,
): Promise<TotpStatusResponseDto> {
return this.totpService.getTotpStatus(BigInt(user.userId)); return this.totpService.getTotpStatus(BigInt(user.userId));
} }
@Post('setup') @Post('setup')
@ApiOperation({ summary: '设置 TOTP', description: '生成 TOTP 密钥,返回二维码和手动输入密钥' }) @ApiOperation({
summary: '设置 TOTP',
description: '生成 TOTP 密钥,返回二维码和手动输入密钥',
})
@ApiResponse({ status: 201, type: SetupTotpResponseDto }) @ApiResponse({ status: 201, type: SetupTotpResponseDto })
async setup(@CurrentUser() user: CurrentUserPayload): Promise<SetupTotpResponseDto> { async setup(
@CurrentUser() user: CurrentUserPayload,
): Promise<SetupTotpResponseDto> {
return this.totpService.setupTotp(BigInt(user.userId)); return this.totpService.setupTotp(BigInt(user.userId));
} }
@Post('enable') @Post('enable')
@ApiOperation({ summary: '启用 TOTP', description: '验证码正确后启用 TOTP 二次验证' }) @ApiOperation({
summary: '启用 TOTP',
description: '验证码正确后启用 TOTP 二次验证',
})
@ApiResponse({ status: 200, description: 'TOTP 已启用' }) @ApiResponse({ status: 200, description: 'TOTP 已启用' })
async enable( async enable(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@ -61,7 +79,10 @@ export class TotpController {
} }
@Post('disable') @Post('disable')
@ApiOperation({ summary: '禁用 TOTP', description: '验证码正确后禁用 TOTP 二次验证' }) @ApiOperation({
summary: '禁用 TOTP',
description: '验证码正确后禁用 TOTP 二次验证',
})
@ApiResponse({ status: 200, description: 'TOTP 已禁用' }) @ApiResponse({ status: 200, description: 'TOTP 已禁用' })
async disable( async disable(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@ -72,13 +93,19 @@ export class TotpController {
} }
@Post('verify') @Post('verify')
@ApiOperation({ summary: '验证 TOTP', description: '验证 TOTP 验证码是否正确' }) @ApiOperation({
summary: '验证 TOTP',
description: '验证 TOTP 验证码是否正确',
})
@ApiResponse({ status: 200, description: '验证结果' }) @ApiResponse({ status: 200, description: '验证结果' })
async verify( async verify(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@Body() dto: VerifyTotpDto, @Body() dto: VerifyTotpDto,
): Promise<{ valid: boolean }> { ): Promise<{ valid: boolean }> {
const valid = await this.totpService.verifyTotp(BigInt(user.userId), dto.code); const valid = await this.totpService.verifyTotp(
BigInt(user.userId),
dto.code,
);
return { valid }; return { valid };
} }
} }

View File

@ -1,404 +1,578 @@
import { import {
Controller, Post, Get, Put, Body, Param, UseGuards, Headers, Controller,
UseInterceptors, UploadedFile, BadRequestException, Post,
} from '@nestjs/common'; Get,
import { FileInterceptor } from '@nestjs/platform-express'; Put,
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger'; Body,
import { UserApplicationService } from '@/application/services/user-application.service'; Param,
import { StorageService } from '@/infrastructure/external/storage/storage.service'; UseGuards,
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard'; Headers,
import { UseInterceptors,
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, UploadedFile,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand, BadRequestException,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand, } from '@nestjs/common';
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery, import { FileInterceptor } from '@nestjs/platform-express';
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand, import {
} from '@/application/commands'; ApiTags,
import { ApiOperation,
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto, ApiBearerAuth,
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto, ApiResponse,
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto, ApiConsumes,
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto, ApiBody,
GenerateBackupCodesDto, RecoverByBackupCodeDto, } from '@nestjs/swagger';
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto, import { UserApplicationService } from '@/application/services/user-application.service';
UserProfileResponseDto, DeviceResponseDto, import { StorageService } from '@/infrastructure/external/storage/storage.service';
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto, import {
VerifySmsCodeDto, SetPasswordDto, JwtAuthGuard,
} from '@/api/dto'; Public,
CurrentUser,
@ApiTags('User') CurrentUserData,
@Controller('user') } from '@/shared/guards/jwt-auth.guard';
@UseGuards(JwtAuthGuard) import {
export class UserAccountController { AutoCreateAccountCommand,
constructor( RecoverByMnemonicCommand,
private readonly userService: UserApplicationService, RecoverByPhoneCommand,
private readonly storageService: StorageService, AutoLoginCommand,
) {} RegisterCommand,
LoginCommand,
@Public() BindPhoneNumberCommand,
@Post('auto-create') UpdateProfileCommand,
@ApiOperation({ summary: '自动创建账户(首次打开APP)' }) SubmitKYCCommand,
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto }) RemoveDeviceCommand,
async autoCreate(@Body() dto: AutoCreateAccountDto) { SendSmsCodeCommand,
return this.userService.autoCreateAccount( GetMyProfileQuery,
new AutoCreateAccountCommand( GetMyDevicesQuery,
dto.deviceId, dto.deviceName, dto.inviterReferralCode, GetUserByReferralCodeQuery,
), GetWalletStatusQuery,
); MarkMnemonicBackedUpCommand,
} VerifySmsCodeCommand,
SetPasswordCommand,
@Public() } from '@/application/commands';
@Post('recover-by-mnemonic') import {
@ApiOperation({ summary: '用序列号+助记词恢复账户' }) AutoCreateAccountDto,
@ApiResponse({ status: 200, type: RecoverAccountResponseDto }) RecoverByMnemonicDto,
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) { RecoverByPhoneDto,
return this.userService.recoverByMnemonic( AutoLoginDto,
new RecoverByMnemonicCommand( SendSmsCodeDto,
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName, RegisterDto,
), LoginDto,
); BindPhoneDto,
} UpdateProfileDto,
BindWalletDto,
@Public() SubmitKYCDto,
@Post('recover-by-phone') RemoveDeviceDto,
@ApiOperation({ summary: '用序列号+手机号恢复账户' }) RevokeMnemonicDto,
@ApiResponse({ status: 200, type: RecoverAccountResponseDto }) FreezeAccountDto,
async recoverByPhone(@Body() dto: RecoverByPhoneDto) { UnfreezeAccountDto,
return this.userService.recoverByPhone( RequestKeyRotationDto,
new RecoverByPhoneCommand( GenerateBackupCodesDto,
dto.accountSequence, dto.phoneNumber, dto.smsCode, RecoverByBackupCodeDto,
dto.newDeviceId, dto.deviceName, AutoCreateAccountResponseDto,
), RecoverAccountResponseDto,
); LoginResponseDto,
} UserProfileResponseDto,
DeviceResponseDto,
@Public() WalletStatusReadyResponseDto,
@Post('auto-login') WalletStatusGeneratingResponseDto,
@ApiOperation({ summary: '自动登录(Token刷新)' }) VerifySmsCodeDto,
@ApiResponse({ status: 200, type: LoginResponseDto }) SetPasswordDto,
async autoLogin(@Body() dto: AutoLoginDto) { LoginWithPasswordDto,
return this.userService.autoLogin( } from '@/api/dto';
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
); @ApiTags('User')
} @Controller('user')
@UseGuards(JwtAuthGuard)
@Public() export class UserAccountController {
@Post('send-sms-code') constructor(
@ApiOperation({ summary: '发送短信验证码' }) private readonly userService: UserApplicationService,
async sendSmsCode(@Body() dto: SendSmsCodeDto) { private readonly storageService: StorageService,
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type)); ) {}
return { message: '验证码已发送' };
} @Public()
@Post('auto-create')
@Public() @ApiOperation({ summary: '自动创建账户(首次打开APP)' })
@Post('verify-sms-code') @ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' }) async autoCreate(@Body() dto: AutoCreateAccountDto) {
@ApiResponse({ status: 200, description: '验证成功' }) return this.userService.autoCreateAccount(
async verifySmsCode(@Body() dto: VerifySmsCodeDto) { new AutoCreateAccountCommand(
await this.userService.verifySmsCode( dto.deviceId,
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'), dto.deviceName,
); dto.inviterReferralCode,
return { message: '验证成功' }; ),
} );
}
@Public()
@Post('register') @Public()
@ApiOperation({ summary: '用户注册(手机号)' }) @Post('recover-by-mnemonic')
@ApiResponse({ status: 200, type: LoginResponseDto }) @ApiOperation({ summary: '用序列号+助记词恢复账户' })
async register(@Body() dto: RegisterDto) { @ApiResponse({ status: 200, type: RecoverAccountResponseDto })
return this.userService.register( async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
new RegisterCommand( return this.userService.recoverByMnemonic(
dto.phoneNumber, dto.smsCode, dto.deviceId, new RecoverByMnemonicCommand(
dto.deviceName, dto.inviterReferralCode, dto.accountSequence,
), dto.mnemonic,
); dto.newDeviceId,
} dto.deviceName,
),
@Public() );
@Post('login') }
@ApiOperation({ summary: '用户登录(手机号)' })
@ApiResponse({ status: 200, type: LoginResponseDto }) @Public()
async login(@Body() dto: LoginDto) { @Post('recover-by-phone')
return this.userService.login( @ApiOperation({ summary: '用序列号+手机号恢复账户' })
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId), @ApiResponse({ status: 200, type: RecoverAccountResponseDto })
); async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
} return this.userService.recoverByPhone(
new RecoverByPhoneCommand(
@Post('bind-phone') dto.accountSequence,
@ApiBearerAuth() dto.phoneNumber,
@ApiOperation({ summary: '绑定手机号' }) dto.smsCode,
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) { dto.newDeviceId,
await this.userService.bindPhoneNumber( dto.deviceName,
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode), ),
); );
return { message: '绑定成功' }; }
}
@Public()
@Post('set-password') @Post('auto-login')
@ApiBearerAuth() @ApiOperation({ summary: '自动登录(Token刷新)' })
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' }) @ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 200, description: '密码设置成功' }) async autoLogin(@Body() dto: AutoLoginDto) {
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) { return this.userService.autoLogin(
await this.userService.setPassword( new AutoLoginCommand(dto.refreshToken, dto.deviceId),
new SetPasswordCommand(user.userId, dto.password), );
); }
return { message: '密码设置成功' };
} @Public()
@Post('send-sms-code')
@Get('my-profile') @ApiOperation({ summary: '发送短信验证码' })
@ApiBearerAuth() async sendSmsCode(@Body() dto: SendSmsCodeDto) {
@ApiOperation({ summary: '查询我的资料' }) await this.userService.sendSmsCode(
@ApiResponse({ status: 200, type: UserProfileResponseDto }) new SendSmsCodeCommand(dto.phoneNumber, dto.type),
async getMyProfile(@CurrentUser() user: CurrentUserData) { );
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId)); return { message: '验证码已发送' };
} }
@Put('update-profile') @Public()
@ApiBearerAuth() @Post('verify-sms-code')
@ApiOperation({ summary: '更新用户资料' }) @ApiOperation({
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) { summary: '验证短信验证码',
await this.userService.updateProfile( description: '仅验证验证码是否正确,不进行登录或注册',
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl), })
); @ApiResponse({ status: 200, description: '验证成功' })
return { message: '更新成功' }; async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
} await this.userService.verifySmsCode(
new VerifySmsCodeCommand(
@Post('submit-kyc') dto.phoneNumber,
@ApiBearerAuth() dto.smsCode,
@ApiOperation({ summary: '提交KYC认证' }) dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) { ),
await this.userService.submitKYC( );
new SubmitKYCCommand( return { message: '验证成功' };
user.userId, dto.realName, dto.idCardNumber, }
dto.idCardFrontUrl, dto.idCardBackUrl,
), @Public()
); @Post('register')
return { message: '提交成功' }; @ApiOperation({ summary: '用户注册(手机号)' })
} @ApiResponse({ status: 200, type: LoginResponseDto })
async register(@Body() dto: RegisterDto) {
@Get('my-devices') return this.userService.register(
@ApiBearerAuth() new RegisterCommand(
@ApiOperation({ summary: '查看我的设备列表' }) dto.phoneNumber,
@ApiResponse({ status: 200, type: [DeviceResponseDto] }) dto.smsCode,
async getMyDevices(@CurrentUser() user: CurrentUserData) { dto.deviceId,
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId)); dto.deviceName,
} dto.inviterReferralCode,
),
@Post('remove-device') );
@ApiBearerAuth() }
@ApiOperation({ summary: '移除设备' })
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) { @Public()
await this.userService.removeDevice( @Post('login')
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId), @ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
); @ApiResponse({ status: 200, type: LoginResponseDto })
return { message: '移除成功' }; async login(@Body() dto: LoginDto) {
} return this.userService.login(
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
@Public() );
@Get('by-referral-code/:code') }
@ApiOperation({ summary: '根据推荐码查询用户' })
async getByReferralCode(@Param('code') code: string) { @Public()
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code)); @Post('login-with-password')
} @ApiOperation({
summary: '用户登录(手机号+密码)',
@Get('wallet') description: '用于账号恢复,使用手机号和密码登录',
@ApiBearerAuth() })
@ApiOperation({ summary: '获取我的钱包状态和地址' }) @ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto }) async loginWithPassword(@Body() dto: LoginWithPasswordDto) {
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto }) return this.userService.loginWithPassword(
async getWalletStatus(@CurrentUser() user: CurrentUserData) { dto.phoneNumber,
return this.userService.getWalletStatus( dto.password,
new GetWalletStatusQuery(user.accountSequence), dto.deviceId,
); );
} }
@Post('wallet/retry') @Post('bind-phone')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' }) @ApiOperation({ summary: '绑定手机号' })
@ApiResponse({ status: 200, description: '重试请求已提交' }) async bindPhone(
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) { @CurrentUser() user: CurrentUserData,
await this.userService.retryWalletGeneration(user.userId); @Body() dto: BindPhoneDto,
return { message: '钱包生成重试已触发,请稍后查询钱包状态' }; ) {
} await this.userService.bindPhoneNumber(
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
@Put('mnemonic/backup') );
@ApiBearerAuth() return { message: '绑定成功' };
@ApiOperation({ summary: '标记助记词已备份' }) }
@ApiResponse({ status: 200, description: '标记成功' })
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) { @Post('set-password')
await this.userService.markMnemonicBackedUp( @ApiBearerAuth()
new MarkMnemonicBackedUpCommand(user.userId), @ApiOperation({
); summary: '设置登录密码',
return { message: '已标记为已备份' }; description: '首次设置或修改登录密码',
} })
@ApiResponse({ status: 200, description: '密码设置成功' })
@Post('mnemonic/revoke') async setPassword(
@ApiBearerAuth() @CurrentUser() user: CurrentUserData,
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' }) @Body() dto: SetPasswordDto,
@ApiResponse({ status: 200, description: '挂失结果' }) ) {
async revokeMnemonic(@CurrentUser() user: CurrentUserData, @Body() dto: RevokeMnemonicDto) { await this.userService.setPassword(
return this.userService.revokeMnemonic(user.userId, dto.reason); new SetPasswordCommand(user.userId, dto.password),
} );
return { message: '密码设置成功' };
@Post('freeze') }
@ApiBearerAuth()
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' }) @Get('my-profile')
@ApiResponse({ status: 200, description: '冻结结果' }) @ApiBearerAuth()
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) { @ApiOperation({ summary: '查询我的资料' })
return this.userService.freezeAccount(user.userId, dto.reason); @ApiResponse({ status: 200, type: UserProfileResponseDto })
} async getMyProfile(@CurrentUser() user: CurrentUserData) {
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
@Post('unfreeze') }
@ApiBearerAuth()
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' }) @Put('update-profile')
@ApiResponse({ status: 200, description: '解冻结果' }) @ApiBearerAuth()
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) { @ApiOperation({ summary: '更新用户资料' })
return this.userService.unfreezeAccount({ async updateProfile(
userId: user.userId, @CurrentUser() user: CurrentUserData,
verifyMethod: dto.verifyMethod, @Body() dto: UpdateProfileDto,
mnemonic: dto.mnemonic, ) {
phoneNumber: dto.phoneNumber, await this.userService.updateProfile(
smsCode: dto.smsCode, new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
}); );
} return { message: '更新成功' };
}
@Post('key-rotation/request')
@ApiBearerAuth() @Post('submit-kyc')
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' }) @ApiBearerAuth()
@ApiResponse({ status: 200, description: '轮换请求结果' }) @ApiOperation({ summary: '提交KYC认证' })
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) { async submitKYC(
return this.userService.requestKeyRotation({ @CurrentUser() user: CurrentUserData,
userId: user.userId, @Body() dto: SubmitKYCDto,
currentMnemonic: dto.currentMnemonic, ) {
reason: dto.reason, await this.userService.submitKYC(
}); new SubmitKYCCommand(
} user.userId,
dto.realName,
@Post('backup-codes/generate') dto.idCardNumber,
@ApiBearerAuth() dto.idCardFrontUrl,
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' }) dto.idCardBackUrl,
@ApiResponse({ status: 200, description: '恢复码列表' }) ),
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) { );
return this.userService.generateBackupCodes({ return { message: '提交成功' };
userId: user.userId, }
mnemonic: dto.mnemonic,
}); @Get('my-devices')
} @ApiBearerAuth()
@ApiOperation({ summary: '查看我的设备列表' })
@Public() @ApiResponse({ status: 200, type: [DeviceResponseDto] })
@Post('recover-by-backup-code') async getMyDevices(@CurrentUser() user: CurrentUserData) {
@ApiOperation({ summary: '使用恢复码恢复账户' }) return this.userService.getMyDevices(
@ApiResponse({ status: 200, type: RecoverAccountResponseDto }) new GetMyDevicesQuery(user.userId, user.deviceId),
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) { );
return this.userService.recoverByBackupCode({ }
accountSequence: dto.accountSequence,
backupCode: dto.backupCode, @Post('remove-device')
newDeviceId: dto.newDeviceId, @ApiBearerAuth()
deviceName: dto.deviceName, @ApiOperation({ summary: '移除设备' })
}); async removeDevice(
} @CurrentUser() user: CurrentUserData,
@Body() dto: RemoveDeviceDto,
@Post('sms/send-withdraw-code') ) {
@ApiBearerAuth() await this.userService.removeDevice(
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' }) new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
@ApiResponse({ status: 200, description: '发送成功' }) );
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) { return { message: '移除成功' };
await this.userService.sendWithdrawSmsCode(user.userId); }
return { message: '验证码已发送' };
} @Public()
@Get('by-referral-code/:code')
@Post('sms/verify-withdraw-code') @ApiOperation({ summary: '根据推荐码查询用户' })
@ApiBearerAuth() async getByReferralCode(@Param('code') code: string) {
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' }) return this.userService.getUserByReferralCode(
@ApiResponse({ status: 200, description: '验证结果' }) new GetUserByReferralCodeQuery(code),
async verifyWithdrawSmsCode( );
@CurrentUser() user: CurrentUserData, }
@Body() body: { code: string },
) { @Get('wallet')
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code); @ApiBearerAuth()
return { valid }; @ApiOperation({ summary: '获取我的钱包状态和地址' })
} @ApiResponse({
status: 200,
@Post('verify-password') description: '钱包已就绪',
@ApiBearerAuth() type: WalletStatusReadyResponseDto,
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' }) })
@ApiResponse({ status: 200, description: '验证结果' }) @ApiResponse({
async verifyPassword( status: 202,
@CurrentUser() user: CurrentUserData, description: '钱包生成中',
@Body() body: { password: string }, type: WalletStatusGeneratingResponseDto,
) { })
const valid = await this.userService.verifyPassword(user.userId, body.password); async getWalletStatus(@CurrentUser() user: CurrentUserData) {
return { valid }; return this.userService.getWalletStatus(
} new GetWalletStatusQuery(user.accountSequence),
);
@Get('users/resolve-address/:accountSequence') }
@ApiBearerAuth()
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' }) @Post('wallet/retry')
@ApiResponse({ status: 200, description: '返回区块链地址' }) @ApiBearerAuth()
@ApiResponse({ status: 404, description: '找不到用户' }) @ApiOperation({
async resolveAccountSequenceToAddress( summary: '手动重试钱包生成',
@Param('accountSequence') accountSequence: string, description: '当钱包生成失败或超时时,用户可手动触发重试',
) { })
// 默认返回 KAVA 链地址(支持所有 EVM 链) @ApiResponse({ status: 200, description: '重试请求已提交' })
const address = await this.userService.resolveAccountSequenceToAddress( async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
accountSequence, await this.userService.retryWalletGeneration(user.userId);
'KAVA', return { message: '钱包生成重试已触发,请稍后查询钱包状态' };
); }
return { address };
} @Put('mnemonic/backup')
@ApiBearerAuth()
@Post('upload-avatar') @ApiOperation({ summary: '标记助记词已备份' })
@ApiBearerAuth() @ApiResponse({ status: 200, description: '标记成功' })
@ApiOperation({ summary: '上传用户头像' }) async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
@ApiConsumes('multipart/form-data') await this.userService.markMnemonicBackedUp(
@ApiBody({ new MarkMnemonicBackedUpCommand(user.userId),
schema: { );
type: 'object', return { message: '已标记为已备份' };
properties: { }
file: {
type: 'string', @Post('mnemonic/revoke')
format: 'binary', @ApiBearerAuth()
description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)', @ApiOperation({
}, summary: '挂失助记词',
}, description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复',
}, })
}) @ApiResponse({ status: 200, description: '挂失结果' })
@ApiResponse({ status: 200, description: '上传成功返回头像URL' }) async revokeMnemonic(
@UseInterceptors(FileInterceptor('file')) @CurrentUser() user: CurrentUserData,
async uploadAvatar( @Body() dto: RevokeMnemonicDto,
@CurrentUser() user: CurrentUserData, ) {
@UploadedFile() file: Express.Multer.File, return this.userService.revokeMnemonic(user.userId, dto.reason);
) { }
// 验证文件是否存在
if (!file) { @Post('freeze')
throw new BadRequestException('请选择要上传的图片'); @ApiBearerAuth()
} @ApiOperation({
summary: '冻结账户',
// 验证文件类型 description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作',
if (!this.storageService.isValidImageType(file.mimetype)) { })
throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp'); @ApiResponse({ status: 200, description: '冻结结果' })
} async freezeAccount(
@CurrentUser() user: CurrentUserData,
// 验证文件大小 @Body() dto: FreezeAccountDto,
if (file.size > this.storageService.maxAvatarSize) { ) {
throw new BadRequestException('图片大小不能超过 5MB'); return this.userService.freezeAccount(user.userId, dto.reason);
} }
// 上传文件 @Post('unfreeze')
const result = await this.storageService.uploadAvatar( @ApiBearerAuth()
user.userId, @ApiOperation({
file.buffer, summary: '解冻账户',
file.mimetype, description: '验证身份后解冻账户,支持助记词或手机号验证',
); })
@ApiResponse({ status: 200, description: '解冻结果' })
// 更新用户头像URL async unfreezeAccount(
await this.userService.updateProfile( @CurrentUser() user: CurrentUserData,
new UpdateProfileCommand(user.userId, undefined, result.url), @Body() dto: UnfreezeAccountDto,
); ) {
return this.userService.unfreezeAccount({
return { userId: user.userId,
message: '上传成功', verifyMethod: dto.verifyMethod,
avatarUrl: result.url, mnemonic: dto.mnemonic,
}; phoneNumber: dto.phoneNumber,
} smsCode: dto.smsCode,
} });
}
@Post('key-rotation/request')
@ApiBearerAuth()
@ApiOperation({
summary: '请求密钥轮换',
description: '验证当前助记词后,请求轮换 MPC 密钥对',
})
@ApiResponse({ status: 200, description: '轮换请求结果' })
async requestKeyRotation(
@CurrentUser() user: CurrentUserData,
@Body() dto: RequestKeyRotationDto,
) {
return this.userService.requestKeyRotation({
userId: user.userId,
currentMnemonic: dto.currentMnemonic,
reason: dto.reason,
});
}
@Post('backup-codes/generate')
@ApiBearerAuth()
@ApiOperation({
summary: '生成恢复码',
description: '验证助记词后生成一组一次性恢复码',
})
@ApiResponse({ status: 200, description: '恢复码列表' })
async generateBackupCodes(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateBackupCodesDto,
) {
return this.userService.generateBackupCodes({
userId: user.userId,
mnemonic: dto.mnemonic,
});
}
@Public()
@Post('recover-by-backup-code')
@ApiOperation({ summary: '使用恢复码恢复账户' })
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
return this.userService.recoverByBackupCode({
accountSequence: dto.accountSequence,
backupCode: dto.backupCode,
newDeviceId: dto.newDeviceId,
deviceName: dto.deviceName,
});
}
@Post('sms/send-withdraw-code')
@ApiBearerAuth()
@ApiOperation({
summary: '发送提取验证短信',
description: '向用户绑定的手机号发送提取验证码',
})
@ApiResponse({ status: 200, description: '发送成功' })
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
await this.userService.sendWithdrawSmsCode(user.userId);
return { message: '验证码已发送' };
}
@Post('sms/verify-withdraw-code')
@ApiBearerAuth()
@ApiOperation({
summary: '验证提取短信验证码',
description: '验证提取操作的短信验证码',
})
@ApiResponse({ status: 200, description: '验证结果' })
async verifyWithdrawSmsCode(
@CurrentUser() user: CurrentUserData,
@Body() body: { code: string },
) {
const valid = await this.userService.verifyWithdrawSmsCode(
user.userId,
body.code,
);
return { valid };
}
@Post('verify-password')
@ApiBearerAuth()
@ApiOperation({
summary: '验证登录密码',
description: '验证用户的登录密码,用于敏感操作二次验证',
})
@ApiResponse({ status: 200, description: '验证结果' })
async verifyPassword(
@CurrentUser() user: CurrentUserData,
@Body() body: { password: string },
) {
const valid = await this.userService.verifyPassword(
user.userId,
body.password,
);
return { valid };
}
@Get('users/resolve-address/:accountSequence')
@ApiBearerAuth()
@ApiOperation({
summary: '解析充值ID到区块链地址',
description: '通过用户的 accountSequence 获取其区块链钱包地址',
})
@ApiResponse({ status: 200, description: '返回区块链地址' })
@ApiResponse({ status: 404, description: '找不到用户' })
async resolveAccountSequenceToAddress(
@Param('accountSequence') accountSequence: string,
) {
// 默认返回 KAVA 链地址(支持所有 EVM 链)
const address = await this.userService.resolveAccountSequenceToAddress(
accountSequence,
'KAVA',
);
return { address };
}
@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

@ -1,367 +1,398 @@
// Request DTOs // Request DTOs
export * from './request'; export * from './request';
// Response DTOs // Response DTOs
export * from './response'; export * from './response';
// 其他通用DTOs // 其他通用DTOs
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator'; import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; IsString,
IsOptional,
export class AutoLoginDto { IsNotEmpty,
@ApiProperty() Matches,
@IsString() IsEnum,
@IsNotEmpty() IsNumber,
refreshToken: string; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ApiProperty()
@IsString() export class AutoLoginDto {
@IsNotEmpty() @ApiProperty()
deviceId: string; @IsString()
} @IsNotEmpty()
refreshToken: string;
export class SendSmsCodeDto {
@ApiProperty({ example: '13800138000' }) @ApiProperty()
@IsString() @IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) @IsNotEmpty()
phoneNumber: string; deviceId: string;
}
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER']) export class SendSmsCodeDto {
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'; @ApiProperty({ example: '13800138000' })
} @IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
export class RegisterDto { phoneNumber: string;
@ApiProperty({ example: '13800138000' })
@IsString() @ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) @IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
phoneNumber: string; type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
}
@ApiProperty({ example: '123456' })
@IsString() export class RegisterDto {
@Matches(/^\d{6}$/, { message: '验证码格式错误' }) @ApiProperty({ example: '13800138000' })
smsCode: string; @IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
@ApiProperty() phoneNumber: string;
@IsString()
@IsNotEmpty() @ApiProperty({ example: '123456' })
deviceId: string; @IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
@ApiPropertyOptional() smsCode: string;
@IsOptional()
@IsString() @ApiProperty()
deviceName?: string; @IsString()
@IsNotEmpty()
@ApiPropertyOptional() deviceId: string;
@IsOptional()
@IsString() @ApiPropertyOptional()
inviterReferralCode?: string; @IsOptional()
} @IsString()
deviceName?: string;
export class LoginDto {
@ApiProperty({ example: '13800138000' }) @ApiPropertyOptional()
@IsString() @IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) @IsString()
phoneNumber: string; inviterReferralCode?: string;
}
@ApiProperty({ example: '123456' })
@IsString() export class LoginDto {
@Matches(/^\d{6}$/, { message: '验证码格式错误' }) @ApiProperty({ example: '13800138000' })
smsCode: string; @IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
@ApiProperty() phoneNumber: string;
@IsString()
@IsNotEmpty() @ApiProperty({ example: '123456' })
deviceId: string; @IsString()
} @Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string;
export class AdminLoginDto {
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' }) @ApiProperty()
@IsString() @IsString()
@IsNotEmpty({ message: '邮箱不能为空' }) @IsNotEmpty()
email: string; deviceId: string;
}
@ApiProperty({ example: 'password123', description: '密码' })
@IsString() export class AdminLoginDto {
@IsNotEmpty({ message: '密码不能为空' }) @ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
password: string; @IsString()
} @IsNotEmpty({ message: '邮箱不能为空' })
email: string;
export class AdminLoginResponseDto {
@ApiProperty() @ApiProperty({ example: 'password123', description: '密码' })
userId: string; @IsString()
@IsNotEmpty({ message: '密码不能为空' })
@ApiProperty({ description: '管理员邮箱' }) password: string;
email: string; }
@ApiProperty({ description: '管理员昵称' }) export class AdminLoginResponseDto {
nickname: string; @ApiProperty()
userId: string;
@ApiProperty({ description: '角色' })
role: string; @ApiProperty({ description: '管理员邮箱' })
email: string;
@ApiProperty()
accessToken: string; @ApiProperty({ description: '管理员昵称' })
nickname: string;
@ApiProperty()
refreshToken: string; @ApiProperty({ description: '角色' })
} role: string;
export class UpdateProfileDto { @ApiProperty()
@ApiPropertyOptional() accessToken: string;
@IsOptional()
@IsString() @ApiProperty()
nickname?: string; refreshToken: string;
}
@ApiPropertyOptional()
@IsOptional() export class UpdateProfileDto {
@IsString() @ApiPropertyOptional()
avatarUrl?: string; @IsOptional()
@IsString()
@ApiPropertyOptional() nickname?: string;
@IsOptional()
@IsString() @ApiPropertyOptional()
address?: string; @IsOptional()
} @IsString()
avatarUrl?: string;
export class BindWalletDto {
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] }) @ApiPropertyOptional()
@IsEnum(['KAVA', 'DST', 'BSC']) @IsOptional()
chainType: string; @IsString()
address?: string;
@ApiProperty() }
@IsString()
@IsNotEmpty() export class BindWalletDto {
address: string; @ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
} @IsEnum(['KAVA', 'DST', 'BSC'])
chainType: string;
export class RemoveDeviceDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
deviceId: string; address: string;
} }
// Response DTOs export class RemoveDeviceDto {
export class AutoCreateAccountResponseDto { @ApiProperty()
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' }) @IsString()
userSerialNum: string; @IsNotEmpty()
deviceId: string;
@ApiProperty({ example: 'ABC123', description: '推荐码' }) }
referralCode: string;
// Response DTOs
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' }) export class AutoCreateAccountResponseDto {
username: string; @ApiProperty({
example: 'D2512110001',
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' }) description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
avatarSvg: string; })
userSerialNum: string;
@ApiProperty({ description: '访问令牌' })
accessToken: string; @ApiProperty({ example: 'ABC123', description: '推荐码' })
referralCode: string;
@ApiProperty({ description: '刷新令牌' })
refreshToken: string; @ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
} username: string;
export class RecoverAccountResponseDto { @ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
@ApiProperty() avatarSvg: string;
userId: string;
@ApiProperty({ description: '访问令牌' })
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) accessToken: string;
accountSequence: string;
@ApiProperty({ description: '刷新令牌' })
@ApiProperty() refreshToken: string;
nickname: string; }
@ApiProperty({ nullable: true }) export class RecoverAccountResponseDto {
avatarUrl: string | null; @ApiProperty()
userId: string;
@ApiProperty()
referralCode: string; @ApiProperty({
example: 'D2512110001',
@ApiProperty() description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
accessToken: string; })
accountSequence: string;
@ApiProperty()
refreshToken: string; @ApiProperty()
} nickname: string;
// 钱包地址响应 @ApiProperty({ nullable: true })
export class WalletAddressesDto { avatarUrl: string | null;
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
kava: string; @ApiProperty()
referralCode: string;
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
dst: string; @ApiProperty()
accessToken: string;
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
bsc: string; @ApiProperty()
} refreshToken: string;
}
// 钱包状态响应 (就绪)
export class WalletStatusReadyResponseDto { // 钱包地址响应
@ApiProperty({ example: 'ready', description: '钱包状态' }) export class WalletAddressesDto {
status: 'ready'; @ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
kava: string;
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
walletAddresses: WalletAddressesDto; @ApiProperty({ example: 'dst1...', description: 'DST链地址' })
dst: string;
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
mnemonic: string; @ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
} bsc: string;
}
// 钱包状态响应 (生成中)
export class WalletStatusGeneratingResponseDto { // 钱包状态响应 (就绪)
@ApiProperty({ example: 'generating', description: '钱包状态' }) export class WalletStatusReadyResponseDto {
status: 'generating'; @ApiProperty({ example: 'ready', description: '钱包状态' })
} status: 'ready';
export class LoginResponseDto { @ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
@ApiProperty() walletAddresses: WalletAddressesDto;
userId: string;
@ApiProperty({
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) example: 'word1 word2 ... word12',
accountSequence: string; description: '助记词 (12词)',
})
@ApiProperty() mnemonic: string;
accessToken: string; }
@ApiProperty() // 钱包状态响应 (生成中)
refreshToken: string; export class WalletStatusGeneratingResponseDto {
} @ApiProperty({ example: 'generating', description: '钱包状态' })
status: 'generating';
// ============ Referral DTOs ============ }
export class GenerateReferralLinkDto { export class LoginResponseDto {
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' }) @ApiProperty()
@IsOptional() userId: string;
@IsString()
channel?: string; @ApiProperty({
example: 'D2512110001',
@ApiPropertyOptional({ description: '活动ID' }) description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
@IsOptional() })
@IsString() accountSequence: string;
campaignId?: string;
} @ApiProperty()
accessToken: string;
export class MeResponseDto {
@ApiProperty() @ApiProperty()
userId: string; refreshToken: string;
}
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
accountSequence: string; // ============ Referral DTOs ============
@ApiProperty({ nullable: true }) export class GenerateReferralLinkDto {
phoneNumber: string | null; @ApiPropertyOptional({
description: '渠道标识: wechat, telegram, twitter 等',
@ApiProperty() })
nickname: string; @IsOptional()
@IsString()
@ApiProperty({ nullable: true }) channel?: string;
avatarUrl: string | null;
@ApiPropertyOptional({ description: '活动ID' })
@ApiProperty({ description: '推荐码' }) @IsOptional()
referralCode: string; @IsString()
campaignId?: string;
@ApiProperty({ description: '完整推荐链接' }) }
referralLink: string;
export class MeResponseDto {
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true }) @ApiProperty()
inviterSequence: string | null; userId: string;
@ApiProperty({ description: '钱包地址列表' }) @ApiProperty({
walletAddresses: Array<{ chainType: string; address: string }>; example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
@ApiProperty() })
kycStatus: string; accountSequence: string;
@ApiProperty() @ApiProperty({ nullable: true })
status: string; phoneNumber: string | null;
@ApiProperty() @ApiProperty()
registeredAt: Date; nickname: string;
}
@ApiProperty({ nullable: true })
export class ReferralValidationResponseDto { avatarUrl: string | null;
@ApiProperty({ description: '推荐码是否有效' })
valid: boolean; @ApiProperty({ description: '推荐码' })
referralCode: string;
@ApiPropertyOptional()
referralCode?: string; @ApiProperty({ description: '完整推荐链接' })
referralLink: string;
@ApiPropertyOptional({ description: '邀请人信息' })
inviterInfo?: { @ApiProperty({
accountSequence: string; // 格式: D + YYMMDD + 5位序号 example: 'D2512110001',
nickname: string; description: '推荐人序列号',
avatarUrl: string | null; nullable: true,
}; })
inviterSequence: string | null;
@ApiPropertyOptional({ description: '错误信息' })
message?: string; @ApiProperty({ description: '钱包地址列表' })
} walletAddresses: Array<{ chainType: string; address: string }>;
export class ReferralLinkResponseDto { @ApiProperty()
@ApiProperty() kycStatus: string;
linkId: string;
@ApiProperty()
@ApiProperty() status: string;
referralCode: string;
@ApiProperty()
@ApiProperty({ description: '短链' }) registeredAt: Date;
shortUrl: string; }
@ApiProperty({ description: '完整链接' }) export class ReferralValidationResponseDto {
fullUrl: string; @ApiProperty({ description: '推荐码是否有效' })
valid: boolean;
@ApiProperty({ nullable: true })
channel: string | null; @ApiPropertyOptional()
referralCode?: string;
@ApiProperty({ nullable: true })
campaignId: string | null; @ApiPropertyOptional({ description: '邀请人信息' })
inviterInfo?: {
@ApiProperty() accountSequence: string; // 格式: D + YYMMDD + 5位序号
createdAt: Date; nickname: string;
} avatarUrl: string | null;
};
export class InviteRecordDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiPropertyOptional({ description: '错误信息' })
accountSequence: string; message?: string;
}
@ApiProperty()
nickname: string; export class ReferralLinkResponseDto {
@ApiProperty()
@ApiProperty({ nullable: true }) linkId: string;
avatarUrl: string | null;
@ApiProperty()
@ApiProperty() referralCode: string;
registeredAt: Date;
@ApiProperty({ description: '短链' })
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' }) shortUrl: string;
level: number;
} @ApiProperty({ description: '完整链接' })
fullUrl: string;
export class ReferralStatsResponseDto {
@ApiProperty() @ApiProperty({ nullable: true })
referralCode: string; channel: string | null;
@ApiProperty({ description: '总邀请人数' }) @ApiProperty({ nullable: true })
totalInvites: number; campaignId: string | null;
@ApiProperty({ description: '直接邀请人数' }) @ApiProperty()
directInvites: number; createdAt: Date;
}
@ApiProperty({ description: '间接邀请人数 (二级)' })
indirectInvites: number; export class InviteRecordDto {
@ApiProperty({
@ApiProperty({ description: '今日邀请' }) example: 'D2512110001',
todayInvites: number; description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
@ApiProperty({ description: '本周邀请' }) accountSequence: string;
thisWeekInvites: number;
@ApiProperty()
@ApiProperty({ description: '本月邀请' }) nickname: string;
thisMonthInvites: number;
@ApiProperty({ nullable: true })
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] }) avatarUrl: string | null;
recentInvites: InviteRecordDto[];
} @ApiProperty()
registeredAt: Date;
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
level: number;
}
export class ReferralStatsResponseDto {
@ApiProperty()
referralCode: string;
@ApiProperty({ description: '总邀请人数' })
totalInvites: number;
@ApiProperty({ description: '直接邀请人数' })
directInvites: number;
@ApiProperty({ description: '间接邀请人数 (二级)' })
indirectInvites: number;
@ApiProperty({ description: '今日邀请' })
todayInvites: number;
@ApiProperty({ description: '本周邀请' })
thisWeekInvites: number;
@ApiProperty({ description: '本月邀请' })
thisMonthInvites: number;
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
recentInvites: InviteRecordDto[];
}

View File

@ -1,41 +1,53 @@
import { IsString, IsOptional, IsNotEmpty, Matches, IsObject } from 'class-validator'; import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; IsString,
IsOptional,
/** IsNotEmpty,
* - JSON Matches,
* IsObject,
*/ } from 'class-validator';
export interface DeviceNameDto { import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
model?: string; // 设备型号
platform?: string; // 平台: ios, android, web /**
osVersion?: string; // 系统版本 * - JSON
brand?: string; // 品牌 *
manufacturer?: string; // 厂商 */
device?: string; // 设备名 export interface DeviceNameDto {
product?: string; // 产品名 model?: string; // 设备型号
hardware?: string; // 硬件名 platform?: string; // 平台: ios, android, web
sdkInt?: number; // SDK 版本 (Android) osVersion?: string; // 系统版本
isPhysicalDevice?: boolean; // 是否真机 brand?: string; // 品牌
[key: string]: unknown; // 允许其他字段 manufacturer?: string; // 厂商
} device?: string; // 设备名
product?: string; // 产品名
export class AutoCreateAccountDto { hardware?: string; // 硬件名
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' }) sdkInt?: number; // SDK 版本 (Android)
@IsString() isPhysicalDevice?: boolean; // 是否真机
@IsNotEmpty() [key: string]: unknown; // 允许其他字段
deviceId: string; }
@ApiPropertyOptional({ export class AutoCreateAccountDto {
description: '设备信息 (JSON 对象)', @ApiProperty({
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' } example: '550e8400-e29b-41d4-a716-446655440000',
}) description: '设备唯一标识',
@IsOptional() })
@IsObject() @IsString()
deviceName?: DeviceNameDto; @IsNotEmpty()
deviceId: string;
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
@IsOptional() @ApiPropertyOptional({
@IsString() description: '设备信息 (JSON 对象)',
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' }) example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' },
inviterReferralCode?: string; })
} @IsOptional()
@IsObject()
deviceName?: DeviceNameDto;
@ApiPropertyOptional({
example: 'RWAABC1234',
description: '邀请人推荐码 (6-20位大写字母和数字)',
})
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
inviterReferralCode?: string;
}

View File

@ -1,14 +1,14 @@
import { IsString, Matches } from 'class-validator'; import { IsString, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class BindPhoneDto { export class BindPhoneDto {
@ApiProperty({ example: '13800138000' }) @ApiProperty({ example: '13800138000' })
@IsString() @IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string; phoneNumber: string;
@ApiProperty({ example: '123456' }) @ApiProperty({ example: '123456' })
@IsString() @IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' }) @Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string; smsCode: string;
} }

View File

@ -2,7 +2,10 @@ import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class GenerateBackupCodesDto { export class GenerateBackupCodesDto {
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' }) @ApiProperty({
example: 'abandon abandon ...',
description: '当前助记词(验证身份用)',
})
@IsString() @IsString()
@IsNotEmpty({ message: '请提供当前助记词' }) @IsNotEmpty({ message: '请提供当前助记词' })
mnemonic: string; mnemonic: string;

View File

@ -1,13 +1,14 @@
export * from './auto-create-account.dto'; export * from './auto-create-account.dto';
export * from './recover-by-mnemonic.dto'; export * from './recover-by-mnemonic.dto';
export * from './recover-by-phone.dto'; export * from './recover-by-phone.dto';
export * from './bind-phone.dto'; export * from './bind-phone.dto';
export * from './submit-kyc.dto'; export * from './submit-kyc.dto';
export * from './revoke-mnemonic.dto'; export * from './revoke-mnemonic.dto';
export * from './freeze-account.dto'; export * from './freeze-account.dto';
export * from './unfreeze-account.dto'; export * from './unfreeze-account.dto';
export * from './request-key-rotation.dto'; export * from './request-key-rotation.dto';
export * from './generate-backup-codes.dto'; export * from './generate-backup-codes.dto';
export * from './recover-by-backup-code.dto'; export * from './recover-by-backup-code.dto';
export * from './verify-sms-code.dto'; export * from './verify-sms-code.dto';
export * from './set-password.dto'; export * from './set-password.dto';
export * from './login-with-password.dto';

View File

@ -0,0 +1,33 @@
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* + DTO
*/
export class LoginWithPasswordDto {
@ApiProperty({
description: '手机号',
example: '13800138000',
})
@IsString()
@IsNotEmpty({ message: '手机号不能为空' })
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phoneNumber!: string;
@ApiProperty({
description: '登录密码',
example: 'password123',
})
@IsString()
@IsNotEmpty({ message: '密码不能为空' })
@MinLength(6, { message: '密码至少6位' })
password!: string;
@ApiProperty({
description: '设备ID',
example: 'device-uuid-12345',
})
@IsString()
@IsNotEmpty({ message: '设备ID不能为空' })
deviceId!: string;
}

View File

@ -11,7 +11,9 @@ export class RecoverByBackupCodeDto {
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' }) @ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
@IsString() @IsString()
@IsNotEmpty({ message: '请提供恢复码' }) @IsNotEmpty({ message: '请提供恢复码' })
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, { message: '恢复码格式不正确' }) @Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, {
message: '恢复码格式不正确',
})
backupCode: string; backupCode: string;
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' }) @ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })

View File

@ -1,24 +1,32 @@
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator'; import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecoverByMnemonicDto { export class RecoverByMnemonicDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
@IsString() example: 'D2512110001',
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' }) description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
accountSequence: string; })
@IsString()
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' }) @Matches(/^D\d{11}$/, {
@IsString() message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
@IsNotEmpty() })
mnemonic: string; accountSequence: string;
@ApiProperty() @ApiProperty({
@IsString() example:
@IsNotEmpty() 'abandon ability able about above absent absorb abstract absurd abuse access accident',
newDeviceId: string; })
@IsString()
@ApiPropertyOptional() @IsNotEmpty()
@IsOptional() mnemonic: string;
@IsString()
deviceName?: string; @ApiProperty()
} @IsString()
@IsNotEmpty()
newDeviceId: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
deviceName?: string;
}

View File

@ -1,29 +1,34 @@
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator'; import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecoverByPhoneDto { export class RecoverByPhoneDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
@IsString() example: 'D2512110001',
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' }) description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
accountSequence: string; })
@IsString()
@ApiProperty({ example: '13800138000' }) @Matches(/^D\d{11}$/, {
@IsString() message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) })
phoneNumber: string; accountSequence: string;
@ApiProperty({ example: '123456' }) @ApiProperty({ example: '13800138000' })
@IsString() @IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' }) @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
smsCode: string; phoneNumber: string;
@ApiProperty() @ApiProperty({ example: '123456' })
@IsString() @IsString()
@IsNotEmpty() @Matches(/^\d{6}$/, { message: '验证码格式错误' })
newDeviceId: string; smsCode: string;
@ApiPropertyOptional() @ApiProperty()
@IsOptional() @IsString()
@IsString() @IsNotEmpty()
deviceName?: string; newDeviceId: string;
}
@ApiPropertyOptional()
@IsOptional()
@IsString()
deviceName?: string;
}

View File

@ -2,7 +2,10 @@ import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class RequestKeyRotationDto { export class RequestKeyRotationDto {
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' }) @ApiProperty({
example: 'abandon abandon ...',
description: '当前助记词(验证身份用)',
})
@IsString() @IsString()
@IsNotEmpty({ message: '请提供当前助记词' }) @IsNotEmpty({ message: '请提供当前助记词' })
currentMnemonic: string; currentMnemonic: string;

View File

@ -1,27 +1,30 @@
import { IsString, IsNotEmpty, Matches } from 'class-validator'; import { IsString, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class SubmitKycDto { export class SubmitKycDto {
@ApiProperty({ example: '张三' }) @ApiProperty({ example: '张三' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
realName: string; realName: string;
@ApiProperty({ example: '110101199001011234' }) @ApiProperty({ example: '110101199001011234' })
@IsString() @IsString()
@Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/, { message: '身份证号格式错误' }) @Matches(
idCardNumber: string; /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/,
{ message: '身份证号格式错误' },
@ApiProperty() )
@IsString() idCardNumber: string;
@IsNotEmpty()
idCardFrontUrl: string; @ApiProperty()
@IsString()
@ApiProperty() @IsNotEmpty()
@IsString() idCardFrontUrl: string;
@IsNotEmpty()
idCardBackUrl: string; @ApiProperty()
} @IsString()
@IsNotEmpty()
// 导出别名以兼容不同命名风格 idCardBackUrl: string;
export { SubmitKycDto as SubmitKYCDto }; }
// 导出别名以兼容不同命名风格
export { SubmitKycDto as SubmitKYCDto };

View File

@ -2,22 +2,34 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UnfreezeAccountDto { export class UnfreezeAccountDto {
@ApiProperty({ example: '确认账户安全', description: '解冻验证方式: mnemonic 或 phone' }) @ApiProperty({
example: '确认账户安全',
description: '解冻验证方式: mnemonic 或 phone',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
verifyMethod: 'mnemonic' | 'phone'; verifyMethod: 'mnemonic' | 'phone';
@ApiPropertyOptional({ example: 'abandon abandon ...', description: '助记词 (verifyMethod=mnemonic时必填)' }) @ApiPropertyOptional({
example: 'abandon abandon ...',
description: '助记词 (verifyMethod=mnemonic时必填)',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
mnemonic?: string; mnemonic?: string;
@ApiPropertyOptional({ example: '+8613800138000', description: '手机号 (verifyMethod=phone时必填)' }) @ApiPropertyOptional({
example: '+8613800138000',
description: '手机号 (verifyMethod=phone时必填)',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
phoneNumber?: string; phoneNumber?: string;
@ApiPropertyOptional({ example: '123456', description: '短信验证码 (verifyMethod=phone时必填)' }) @ApiPropertyOptional({
example: '123456',
description: '短信验证码 (verifyMethod=phone时必填)',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
smsCode?: string; smsCode?: string;

View File

@ -18,6 +18,8 @@ export class VerifySmsCodeDto {
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
}) })
@IsString() @IsString()
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], { message: '无效的验证码类型' }) @IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
message: '无效的验证码类型',
})
type: string; type: string;
} }

View File

@ -1,21 +1,21 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class DeviceDto { export class DeviceDto {
@ApiProperty() @ApiProperty()
deviceId: string; deviceId: string;
@ApiProperty() @ApiProperty()
deviceName: string; deviceName: string;
@ApiProperty() @ApiProperty()
addedAt: Date; addedAt: Date;
@ApiProperty() @ApiProperty()
lastActiveAt: Date; lastActiveAt: Date;
@ApiProperty() @ApiProperty()
isCurrent: boolean; isCurrent: boolean;
} }
// 导出别名以兼容其他命名方式 // 导出别名以兼容其他命名方式
export { DeviceDto as DeviceResponseDto }; export { DeviceDto as DeviceResponseDto };

View File

@ -1,2 +1,2 @@
export * from './user-profile.dto'; export * from './user-profile.dto';
export * from './device.dto'; export * from './device.dto';

View File

@ -1,58 +1,61 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class WalletAddressDto { export class WalletAddressDto {
@ApiProperty() @ApiProperty()
chainType: string; chainType: string;
@ApiProperty() @ApiProperty()
address: string; address: string;
} }
export class KycInfoDto { export class KycInfoDto {
@ApiProperty() @ApiProperty()
realName: string; realName: string;
@ApiProperty() @ApiProperty()
idCardNumber: string; idCardNumber: string;
} }
export class UserProfileDto { export class UserProfileDto {
@ApiProperty() @ApiProperty()
userId: string; userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
accountSequence: string; example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
@ApiProperty({ nullable: true }) })
phoneNumber: string | null; accountSequence: string;
@ApiProperty() @ApiProperty({ nullable: true })
nickname: string; phoneNumber: string | null;
@ApiProperty({ nullable: true }) @ApiProperty()
avatarUrl: string | null; nickname: string;
@ApiProperty() @ApiProperty({ nullable: true })
referralCode: string; avatarUrl: string | null;
@ApiProperty({ type: [WalletAddressDto] }) @ApiProperty()
walletAddresses: WalletAddressDto[]; referralCode: string;
@ApiProperty() @ApiProperty({ type: [WalletAddressDto] })
kycStatus: string; walletAddresses: WalletAddressDto[];
@ApiProperty({ type: KycInfoDto, nullable: true }) @ApiProperty()
kycInfo: KycInfoDto | null; kycStatus: string;
@ApiProperty() @ApiProperty({ type: KycInfoDto, nullable: true })
status: string; kycInfo: KycInfoDto | null;
@ApiProperty() @ApiProperty()
registeredAt: Date; status: string;
@ApiProperty({ nullable: true }) @ApiProperty()
lastLoginAt: Date | null; registeredAt: Date;
}
@ApiProperty({ nullable: true })
// 导出别名以兼容其他命名方式 lastLoginAt: Date | null;
export { UserProfileDto as UserProfileResponseDto }; }
// 导出别名以兼容其他命名方式
export { UserProfileDto as UserProfileResponseDto };

View File

@ -1,47 +1,55 @@
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator'; import {
ValidatorConstraint,
@ValidatorConstraint({ name: 'isChinesePhone', async: false }) ValidatorConstraintInterface,
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface { ValidationArguments,
validate(phone: string, args: ValidationArguments): boolean { registerDecorator,
return /^1[3-9]\d{9}$/.test(phone); ValidationOptions,
} } from 'class-validator';
defaultMessage(args: ValidationArguments): string { @ValidatorConstraint({ name: 'isChinesePhone', async: false })
return '手机号格式错误'; export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
} validate(phone: string, args: ValidationArguments): boolean {
} return /^1[3-9]\d{9}$/.test(phone);
}
export function IsChinesePhone(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) { defaultMessage(args: ValidationArguments): string {
registerDecorator({ return '手机号格式错误';
target: object.constructor, }
propertyName: propertyName, }
options: validationOptions,
constraints: [], export function IsChinesePhone(validationOptions?: ValidationOptions) {
validator: IsChinesePhoneConstraint, return function (object: object, propertyName: string) {
}); registerDecorator({
}; target: object.constructor,
} propertyName: propertyName,
options: validationOptions,
@ValidatorConstraint({ name: 'isChineseIdCard', async: false }) constraints: [],
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface { validator: IsChinesePhoneConstraint,
validate(idCard: string, args: ValidationArguments): boolean { });
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard); };
} }
defaultMessage(args: ValidationArguments): string { @ValidatorConstraint({ name: 'isChineseIdCard', async: false })
return '身份证号格式错误'; export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
} validate(idCard: string, args: ValidationArguments): boolean {
} return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
idCard,
export function IsChineseIdCard(validationOptions?: ValidationOptions) { );
return function (object: Object, propertyName: string) { }
registerDecorator({
target: object.constructor, defaultMessage(args: ValidationArguments): string {
propertyName: propertyName, return '身份证号格式错误';
options: validationOptions, }
constraints: [], }
validator: IsChineseIdCardConstraint,
}); export function IsChineseIdCard(validationOptions?: ValidationOptions) {
}; return function (object: object, propertyName: string) {
} registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsChineseIdCardConstraint,
});
};
}

View File

@ -1,155 +1,186 @@
import { Module, Global } from '@nestjs/common'; import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core'; import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
// Config // Config
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config'; import {
appConfig,
// Controllers databaseConfig,
import { UserAccountController } from '@/api/controllers/user-account.controller'; jwtConfig,
import { HealthController } from '@/api/controllers/health.controller'; redisConfig,
import { ReferralsController } from '@/api/controllers/referrals.controller'; kafkaConfig,
import { AuthController } from '@/api/controllers/auth.controller'; smsConfig,
import { TotpController } from '@/api/controllers/totp.controller'; walletConfig,
} from '@/config';
// Application Services
import { UserApplicationService } from '@/application/services/user-application.service'; // Controllers
import { TokenService } from '@/application/services/token.service'; import { UserAccountController } from '@/api/controllers/user-account.controller';
import { TotpService } from '@/application/services/totp.service'; import { HealthController } from '@/api/controllers/health.controller';
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler'; import { ReferralsController } from '@/api/controllers/referrals.controller';
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler'; import { AuthController } from '@/api/controllers/auth.controller';
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task'; import { TotpController } from '@/api/controllers/totp.controller';
// Domain Services // Application Services
import { import { UserApplicationService } from '@/application/services/user-application.service';
AccountSequenceGeneratorService, UserValidatorService, import { TokenService } from '@/application/services/token.service';
} from '@/domain/services'; import { TotpService } from '@/application/services/totp.service';
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface'; import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
// Infrastructure
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; // Domain Services
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl'; import {
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl'; AccountSequenceGeneratorService,
import { RedisService } from '@/infrastructure/redis/redis.service'; UserValidatorService,
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; } from '@/domain/services';
import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service'; import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service'; import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service'; // Infrastructure
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { StorageService } from '@/infrastructure/external/storage/storage.service'; import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
// Shared import { RedisService } from '@/infrastructure/redis/redis.service';
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service';
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
// ============ Infrastructure Module ============ import { SmsService } from '@/infrastructure/external/sms/sms.service';
@Global() import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
@Module({ import {
imports: [ MpcClientService,
ConfigModule, MpcWalletService,
HttpModule.register({ } from '@/infrastructure/external/mpc';
timeout: 300000, import { StorageService } from '@/infrastructure/external/storage/storage.service';
maxRedirects: 5,
}), // Shared
], import {
providers: [ GlobalExceptionFilter,
PrismaService, TransformInterceptor,
RedisService, } from '@/shared/filters/global-exception.filter';
EventPublisherService, import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
MpcEventConsumerService,
BlockchainEventConsumerService, // ============ Infrastructure Module ============
SmsService, @Global()
MpcClientService, @Module({
MpcWalletService, imports: [
BlockchainClientService, ConfigModule,
StorageService, HttpModule.register({
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl }, timeout: 300000,
], maxRedirects: 5,
exports: [ }),
PrismaService, ],
RedisService, providers: [
EventPublisherService, PrismaService,
MpcEventConsumerService, RedisService,
BlockchainEventConsumerService, EventPublisherService,
SmsService, MpcEventConsumerService,
MpcClientService, BlockchainEventConsumerService,
MpcWalletService, SmsService,
BlockchainClientService, MpcClientService,
StorageService, MpcWalletService,
MPC_KEY_SHARE_REPOSITORY, BlockchainClientService,
], StorageService,
}) { provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
export class InfrastructureModule {} ],
exports: [
// ============ Domain Module ============ PrismaService,
@Module({ RedisService,
imports: [InfrastructureModule], EventPublisherService,
providers: [ MpcEventConsumerService,
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl }, BlockchainEventConsumerService,
AccountSequenceGeneratorService, SmsService,
UserValidatorService, MpcClientService,
], MpcWalletService,
exports: [ BlockchainClientService,
USER_ACCOUNT_REPOSITORY, StorageService,
AccountSequenceGeneratorService, MPC_KEY_SHARE_REPOSITORY,
UserValidatorService, ],
], })
}) export class InfrastructureModule {}
export class DomainModule {}
// ============ Domain Module ============
// ============ Application Module ============ @Module({
@Module({ imports: [InfrastructureModule],
imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()], providers: [
providers: [ { provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
UserApplicationService, AccountSequenceGeneratorService,
TokenService, UserValidatorService,
TotpService, ],
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化 exports: [
BlockchainWalletHandler, USER_ACCOUNT_REPOSITORY,
MpcKeygenCompletedHandler, AccountSequenceGeneratorService,
// Tasks - 定时任务 UserValidatorService,
WalletRetryTask, ],
], })
exports: [UserApplicationService, TokenService, TotpService], export class DomainModule {}
})
export class ApplicationModule {} // ============ Application Module ============
@Module({
// ============ API Module ============ imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()],
@Module({ providers: [
imports: [ApplicationModule], UserApplicationService,
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController], TokenService,
}) TotpService,
export class ApiModule {} // Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
BlockchainWalletHandler,
// ============ App Module ============ MpcKeygenCompletedHandler,
@Module({ // Tasks - 定时任务
imports: [ WalletRetryTask,
ConfigModule.forRoot({ ],
isGlobal: true, exports: [UserApplicationService, TokenService, TotpService],
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig], })
}), export class ApplicationModule {}
JwtModule.registerAsync({
global: true, // ============ API Module ============
inject: [ConfigService], @Module({
useFactory: (configService: ConfigService) => ({ imports: [ApplicationModule],
secret: configService.get<string>('JWT_SECRET'), controllers: [
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') }, HealthController,
}), UserAccountController,
}), ReferralsController,
InfrastructureModule, AuthController,
DomainModule, TotpController,
ApplicationModule, ],
ApiModule, })
], export class ApiModule {}
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter }, // ============ App Module ============
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, @Module({
{ provide: APP_GUARD, useClass: JwtAuthGuard }, imports: [
], ConfigModule.forRoot({
}) isGlobal: true,
export class AppModule {} load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
kafkaConfig,
smsConfig,
walletConfig,
],
}),
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
},
}),
}),
InfrastructureModule,
DomainModule,
ApplicationModule,
ApiModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}

View File

@ -1,45 +1,45 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UserApplicationService } from './services/user-application.service'; import { UserApplicationService } from './services/user-application.service';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
import { TotpService } from './services/totp.service'; import { TotpService } from './services/totp.service';
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler'; import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler'; import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler'; import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler'; import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler'; import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler'; import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler'; import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler'; import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
import { DomainModule } from '@/domain/domain.module'; import { DomainModule } from '@/domain/domain.module';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
@Module({ @Module({
imports: [DomainModule, InfrastructureModule], imports: [DomainModule, InfrastructureModule],
providers: [ providers: [
UserApplicationService, UserApplicationService,
TokenService, TokenService,
TotpService, TotpService,
AutoCreateAccountHandler, AutoCreateAccountHandler,
RecoverByMnemonicHandler, RecoverByMnemonicHandler,
RecoverByPhoneHandler, RecoverByPhoneHandler,
BindPhoneHandler, BindPhoneHandler,
GetMyProfileHandler, GetMyProfileHandler,
GetMyDevicesHandler, GetMyDevicesHandler,
// MPC Event Handlers // MPC Event Handlers
MpcKeygenCompletedHandler, MpcKeygenCompletedHandler,
// Blockchain Event Handlers // Blockchain Event Handlers
BlockchainWalletHandler, BlockchainWalletHandler,
], ],
exports: [ exports: [
UserApplicationService, UserApplicationService,
TokenService, TokenService,
TotpService, TotpService,
AutoCreateAccountHandler, AutoCreateAccountHandler,
RecoverByMnemonicHandler, RecoverByMnemonicHandler,
RecoverByPhoneHandler, RecoverByPhoneHandler,
BindPhoneHandler, BindPhoneHandler,
GetMyProfileHandler, GetMyProfileHandler,
GetMyDevicesHandler, GetMyDevicesHandler,
], ],
}) })
export class ApplicationModule {} export class ApplicationModule {}

View File

@ -1,9 +1,9 @@
import { DeviceNameInput } from '../index'; import { DeviceNameInput } from '../index';
export class AutoCreateAccountCommand { export class AutoCreateAccountCommand {
constructor( constructor(
public readonly deviceId: string, public readonly deviceId: string,
public readonly deviceName?: DeviceNameInput, public readonly deviceName?: DeviceNameInput,
public readonly inviterReferralCode?: string, public readonly inviterReferralCode?: string,
) {} ) {}
} }

View File

@ -1,95 +1,113 @@
import { Injectable, Inject, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { AutoCreateAccountCommand } from './auto-create-account.command'; import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; UserAccountRepository,
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services'; USER_ACCOUNT_REPOSITORY,
import { ReferralCode, AccountSequence } from '@/domain/value-objects'; } from '@/domain/repositories/user-account.repository.interface';
import { TokenService } from '@/application/services/token.service'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import {
import { ApplicationError } from '@/shared/exceptions/domain.exception'; AccountSequenceGeneratorService,
import { AutoCreateAccountResult } from '../index'; UserValidatorService,
import { generateIdentity } from '@/shared/utils'; } from '@/domain/services';
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
@Injectable() import { TokenService } from '@/application/services/token.service';
export class AutoCreateAccountHandler { import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
private readonly logger = new Logger(AutoCreateAccountHandler.name); import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AutoCreateAccountResult } from '../index';
constructor( import { generateIdentity } from '@/shared/utils';
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository, @Injectable()
private readonly sequenceGenerator: AccountSequenceGeneratorService, export class AutoCreateAccountHandler {
private readonly validatorService: UserValidatorService, private readonly logger = new Logger(AutoCreateAccountHandler.name);
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService, constructor(
) {} @Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> { private readonly sequenceGenerator: AccountSequenceGeneratorService,
this.logger.log(`Creating account for device: ${command.deviceId}`); private readonly validatorService: UserValidatorService,
private readonly tokenService: TokenService,
// 1. 验证设备ID private readonly eventPublisher: EventPublisherService,
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId); ) {}
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
async execute(
// 2. 验证邀请码 command: AutoCreateAccountCommand,
let inviterSequence: AccountSequence | null = null; ): Promise<AutoCreateAccountResult> {
if (command.inviterReferralCode) { this.logger.log(`Creating account for device: ${command.deviceId}`);
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode); // 1. 验证设备ID
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!); const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
const inviter = await this.userRepository.findByReferralCode(referralCode); command.deviceId,
inviterSequence = inviter!.accountSequence; );
} if (!deviceCheck.isValid)
throw new ApplicationError(deviceCheck.errorMessage!);
// 3. 生成用户序列号
const accountSequence = await this.sequenceGenerator.generateNextUserSequence(); // 2. 验证邀请码
let inviterSequence: AccountSequence | null = null;
// 4. 生成用户名和头像 if (command.inviterReferralCode) {
const identity = generateIdentity(accountSequence.value); const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation =
// 5. 构建设备名称,保存完整的设备信息 JSON await this.validatorService.validateReferralCode(referralCode);
let deviceNameStr = '未命名设备'; if (!referralValidation.isValid)
if (command.deviceName) { throw new ApplicationError(referralValidation.errorMessage!);
const parts: string[] = []; const inviter =
if (command.deviceName.model) parts.push(command.deviceName.model); await this.userRepository.findByReferralCode(referralCode);
if (command.deviceName.platform) parts.push(command.deviceName.platform); inviterSequence = inviter!.accountSequence;
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion); }
if (parts.length > 0) deviceNameStr = parts.join(' ');
} // 3. 生成用户序列号
const accountSequence =
// 6. 创建账户 - 传递完整的 deviceName JSON await this.sequenceGenerator.generateNextUserSequence();
const account = UserAccount.createAutomatic({
accountSequence, // 4. 生成用户名和头像
initialDeviceId: command.deviceId, const identity = generateIdentity(accountSequence.value);
deviceName: deviceNameStr,
deviceInfo: command.deviceName, // 100% 保持原样存储 // 5. 构建设备名称,保存完整的设备信息 JSON
inviterSequence, let deviceNameStr = '未命名设备';
nickname: identity.username, if (command.deviceName) {
avatarSvg: identity.avatarSvg, const parts: string[] = [];
}); if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform);
// 7. 保存账户 if (command.deviceName.osVersion)
await this.userRepository.save(account); parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' ');
// 8. 生成 Token }
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.toString(), // 6. 创建账户 - 传递完整的 deviceName JSON
accountSequence: account.accountSequence.value, const account = UserAccount.createAutomatic({
deviceId: command.deviceId, accountSequence,
}); initialDeviceId: command.deviceId,
deviceName: deviceNameStr,
// 9. 发布领域事件 deviceInfo: command.deviceName, // 100% 保持原样存储
await this.eventPublisher.publishAll(account.domainEvents); inviterSequence,
account.clearDomainEvents(); nickname: identity.username,
avatarSvg: identity.avatarSvg,
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`); });
return { // 7. 保存账户
userSerialNum: account.accountSequence.value, await this.userRepository.save(account);
referralCode: account.referralCode.value,
username: account.nickname, // 8. 生成 Token
avatarSvg: account.avatarUrl || identity.avatarSvg, const tokens = await this.tokenService.generateTokenPair({
accessToken: tokens.accessToken, userId: account.userId.toString(),
refreshToken: tokens.refreshToken, accountSequence: account.accountSequence.value,
}; deviceId: command.deviceId,
} });
}
// 9. 发布领域事件
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
this.logger.log(
`Account created: sequence=${accountSequence.value}, username=${identity.username}`,
);
return {
userSerialNum: account.accountSequence.value,
referralCode: account.referralCode.value,
username: account.nickname,
avatarSvg: account.avatarUrl || identity.avatarSvg,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
}

View File

@ -1,7 +1,7 @@
export class BindPhoneCommand { export class BindPhoneCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
) {} ) {}
} }

View File

@ -1,37 +1,47 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { BindPhoneCommand } from './bind-phone.command'; import { BindPhoneCommand } from './bind-phone.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { UserValidatorService } from '@/domain/services'; UserAccountRepository,
import { UserId, PhoneNumber } from '@/domain/value-objects'; USER_ACCOUNT_REPOSITORY,
import { RedisService } from '@/infrastructure/redis/redis.service'; } from '@/domain/repositories/user-account.repository.interface';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { UserValidatorService } from '@/domain/services';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { UserId, PhoneNumber } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service';
@Injectable() import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
export class BindPhoneHandler { import { ApplicationError } from '@/shared/exceptions/domain.exception';
constructor(
@Inject(USER_ACCOUNT_REPOSITORY) @Injectable()
private readonly userRepository: UserAccountRepository, export class BindPhoneHandler {
private readonly validatorService: UserValidatorService, constructor(
private readonly redisService: RedisService, @Inject(USER_ACCOUNT_REPOSITORY)
private readonly eventPublisher: EventPublisherService, private readonly userRepository: UserAccountRepository,
) {} private readonly validatorService: UserValidatorService,
private readonly redisService: RedisService,
async execute(command: BindPhoneCommand): Promise<void> { private readonly eventPublisher: EventPublisherService,
const account = await this.userRepository.findById(UserId.create(command.userId)); ) {}
if (!account) throw new ApplicationError('用户不存在');
async execute(command: BindPhoneCommand): Promise<void> {
const phoneNumber = PhoneNumber.create(command.phoneNumber); const account = await this.userRepository.findById(
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`); UserId.create(command.userId),
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); );
if (!account) throw new ApplicationError('用户不存在');
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!); const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(
account.bindPhoneNumber(phoneNumber); `sms:bind:${phoneNumber.value}`,
await this.userRepository.save(account); );
await this.redisService.delete(`sms:bind:${phoneNumber.value}`); if (cachedCode !== command.smsCode)
await this.eventPublisher.publishAll(account.domainEvents); throw new ApplicationError('验证码错误或已过期');
account.clearDomainEvents();
} const validation =
} await this.validatorService.validatePhoneNumber(phoneNumber);
if (!validation.isValid)
throw new ApplicationError(validation.errorMessage!);
account.bindPhoneNumber(phoneNumber);
await this.userRepository.save(account);
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
}

View File

@ -1,312 +1,313 @@
// ============ Types ============ // ============ Types ============
// 设备信息输入 - 100% 保持前端传递的原样存储 // 设备信息输入 - 100% 保持前端传递的原样存储
export interface DeviceNameInput { export interface DeviceNameInput {
model?: string; // iPhone 15 Pro, Pixel 8 model?: string; // iPhone 15 Pro, Pixel 8
platform?: string; // ios, android, web platform?: string; // ios, android, web
osVersion?: string; // iOS 17.2, Android 14 osVersion?: string; // iOS 17.2, Android 14
[key: string]: unknown; // 允许任意其他字段 [key: string]: unknown; // 允许任意其他字段
} }
// ============ Commands ============ // ============ Commands ============
export class AutoCreateAccountCommand { export class AutoCreateAccountCommand {
constructor( constructor(
public readonly deviceId: string, public readonly deviceId: string,
public readonly deviceName?: DeviceNameInput, public readonly deviceName?: DeviceNameInput,
public readonly inviterReferralCode?: string, public readonly inviterReferralCode?: string,
) {} ) {}
} }
export class RecoverByMnemonicCommand { export class RecoverByMnemonicCommand {
constructor( constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly mnemonic: string, public readonly mnemonic: string,
public readonly newDeviceId: string, public readonly newDeviceId: string,
public readonly deviceName?: string, public readonly deviceName?: string,
) {} ) {}
} }
export class RecoverByPhoneCommand { export class RecoverByPhoneCommand {
constructor( constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
public readonly newDeviceId: string, public readonly newDeviceId: string,
public readonly deviceName?: string, public readonly deviceName?: string,
) {} ) {}
} }
export class AutoLoginCommand { export class AutoLoginCommand {
constructor( constructor(
public readonly refreshToken: string, public readonly refreshToken: string,
public readonly deviceId: string, public readonly deviceId: string,
) {} ) {}
} }
export class RegisterCommand { export class RegisterCommand {
constructor( constructor(
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
public readonly deviceId: string, public readonly deviceId: string,
public readonly deviceName?: string, public readonly deviceName?: string,
public readonly inviterReferralCode?: string, public readonly inviterReferralCode?: string,
) {} ) {}
} }
export class LoginCommand { export class LoginCommand {
constructor( constructor(
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
public readonly deviceId: string, public readonly deviceId: string,
) {} ) {}
} }
export class BindPhoneNumberCommand { export class BindPhoneNumberCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
) {} ) {}
} }
export class UpdateProfileCommand { export class UpdateProfileCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly nickname?: string, public readonly nickname?: string,
public readonly avatarUrl?: string, public readonly avatarUrl?: string,
) {} ) {}
} }
export class BindWalletAddressCommand { export class BindWalletAddressCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly chainType: string, public readonly chainType: string,
public readonly address: string, public readonly address: string,
) {} ) {}
} }
export class SubmitKYCCommand { export class SubmitKYCCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly realName: string, public readonly realName: string,
public readonly idCardNumber: string, public readonly idCardNumber: string,
public readonly idCardFrontUrl: string, public readonly idCardFrontUrl: string,
public readonly idCardBackUrl: string, public readonly idCardBackUrl: string,
) {} ) {}
} }
export class ReviewKYCCommand { export class ReviewKYCCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly approved: boolean, public readonly approved: boolean,
public readonly reason?: string, public readonly reason?: string,
) {} ) {}
} }
export class RemoveDeviceCommand { export class RemoveDeviceCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly currentDeviceId: string, public readonly currentDeviceId: string,
public readonly deviceIdToRemove: string, public readonly deviceIdToRemove: string,
) {} ) {}
} }
export class SendSmsCodeCommand { export class SendSmsCodeCommand {
constructor( constructor(
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER', public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
) {} ) {}
} }
// ============ Queries ============ // ============ Queries ============
export class GetMyProfileQuery { export class GetMyProfileQuery {
constructor(public readonly userId: string) {} constructor(public readonly userId: string) {}
} }
export class GetMyDevicesQuery { export class GetMyDevicesQuery {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly currentDeviceId: string, public readonly currentDeviceId: string,
) {} ) {}
} }
export class GetUserByReferralCodeQuery { export class GetUserByReferralCodeQuery {
constructor(public readonly referralCode: string) {} constructor(public readonly referralCode: string) {}
} }
export class ValidateReferralCodeQuery { export class ValidateReferralCodeQuery {
constructor(public readonly referralCode: string) {} constructor(public readonly referralCode: string) {}
} }
export class GetReferralStatsQuery { export class GetReferralStatsQuery {
constructor(public readonly userId: string) {} constructor(public readonly userId: string) {}
} }
export class GenerateReferralLinkCommand { export class GenerateReferralLinkCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等 public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
public readonly campaignId?: string, // 活动ID public readonly campaignId?: string, // 活动ID
) {} ) {}
} }
export class GetWalletStatusQuery { export class GetWalletStatusQuery {
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号 constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
} }
export class MarkMnemonicBackedUpCommand { export class MarkMnemonicBackedUpCommand {
constructor(public readonly userId: string) {} constructor(public readonly userId: string) {}
} }
export class VerifySmsCodeCommand { export class VerifySmsCodeCommand {
constructor( constructor(
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER', public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
) {} ) {}
} }
export class SetPasswordCommand { export class SetPasswordCommand {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly password: string, public readonly password: string,
) {} ) {}
} }
// ============ Results ============ // ============ Results ============
// 钱包状态 // 钱包状态
export type WalletStatus = 'generating' | 'ready' | 'failed'; export type WalletStatus = 'generating' | 'ready' | 'failed';
export interface WalletStatusResult { export interface WalletStatusResult {
status: WalletStatus; status: WalletStatus;
walletAddresses?: { walletAddresses?: {
kava: string; kava: string;
dst: string; dst: string;
bsc: string; bsc: string;
}; };
mnemonic?: string; // 助记词 (ready 状态时返回) mnemonic?: string; // 助记词 (ready 状态时返回)
errorMessage?: string; // 失败原因 (failed 状态时返回) errorMessage?: string; // 失败原因 (failed 状态时返回)
} }
export interface AutoCreateAccountResult { export interface AutoCreateAccountResult {
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号) userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
referralCode: string; // 推荐码 referralCode: string; // 推荐码
username: string; // 随机用户名 username: string; // 随机用户名
avatarSvg: string; // 随机SVG头像 avatarSvg: string; // 随机SVG头像
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
} }
export interface RecoverAccountResult { export interface RecoverAccountResult {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
referralCode: string; referralCode: string;
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
} }
export interface AutoLoginResult { export interface AutoLoginResult {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
} }
export interface RegisterResult { export interface RegisterResult {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; referralCode: string;
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
} }
export interface LoginResult { export interface LoginResult {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
} }
export interface UserProfileDTO { export interface UserProfileDTO {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
phoneNumber: string | null; phoneNumber: string | null;
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
referralCode: string; referralCode: string;
walletAddresses: Array<{ chainType: string; address: string }>; walletAddresses: Array<{ chainType: string; address: string }>;
kycStatus: string; kycStatus: string;
kycInfo: { realName: string; idCardNumber: string } | null; kycInfo: { realName: string; idCardNumber: string } | null;
status: string; status: string;
registeredAt: Date; registeredAt: Date;
lastLoginAt: Date | null; lastLoginAt: Date | null;
} }
export interface DeviceDTO { export interface DeviceDTO {
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
addedAt: Date; addedAt: Date;
lastActiveAt: Date; lastActiveAt: Date;
isCurrent: boolean; isCurrent: boolean;
} }
export interface UserBriefDTO { export interface UserBriefDTO {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
} }
export interface ReferralCodeValidationResult { export interface ReferralCodeValidationResult {
valid: boolean; valid: boolean;
referralCode?: string; referralCode?: string;
inviterInfo?: { inviterInfo?: {
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
}; };
message?: string; message?: string;
} }
export interface ReferralLinkResult { export interface ReferralLinkResult {
linkId: string; linkId: string;
referralCode: string; referralCode: string;
shortUrl: string; shortUrl: string;
fullUrl: string; fullUrl: string;
channel: string | null; channel: string | null;
campaignId: string | null; campaignId: string | null;
createdAt: Date; createdAt: Date;
} }
export interface ReferralStatsResult { export interface ReferralStatsResult {
referralCode: string; referralCode: string;
totalInvites: number; // 总邀请人数 totalInvites: number; // 总邀请人数
directInvites: number; // 直接邀请人数 directInvites: number; // 直接邀请人数
indirectInvites: number; // 间接邀请人数 (二级) indirectInvites: number; // 间接邀请人数 (二级)
todayInvites: number; // 今日邀请 todayInvites: number; // 今日邀请
thisWeekInvites: number; // 本周邀请 thisWeekInvites: number; // 本周邀请
thisMonthInvites: number; // 本月邀请 thisMonthInvites: number; // 本月邀请
recentInvites: Array<{ // 最近邀请记录 recentInvites: Array<{
accountSequence: string; // 格式: D + YYMMDD + 5位序号 // 最近邀请记录
nickname: string; accountSequence: string; // 格式: D + YYMMDD + 5位序号
avatarUrl: string | null; nickname: string;
registeredAt: Date; avatarUrl: string | null;
level: number; // 1=直接, 2=间接 registeredAt: Date;
}>; level: number; // 1=直接, 2=间接
} }>;
}
export interface MeResult {
userId: string; export interface MeResult {
accountSequence: string; // 格式: D + YYMMDD + 5位序号 userId: string;
phoneNumber: string | null; accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; phoneNumber: string | null;
avatarUrl: string | null; nickname: string;
referralCode: string; avatarUrl: string | null;
referralLink: string; // 完整推荐链接 referralCode: string;
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号) referralLink: string; // 完整推荐链接
walletAddresses: Array<{ chainType: string; address: string }>; inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
kycStatus: string; walletAddresses: Array<{ chainType: string; address: string }>;
status: string; kycStatus: string;
registeredAt: Date; status: string;
} registeredAt: Date;
}

View File

@ -1,8 +1,8 @@
export class RecoverByMnemonicCommand { export class RecoverByMnemonicCommand {
constructor( constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly mnemonic: string, public readonly mnemonic: string,
public readonly newDeviceId: string, public readonly newDeviceId: string,
public readonly deviceName?: string, public readonly deviceName?: string,
) {} ) {}
} }

View File

@ -1,84 +1,106 @@
import { Injectable, Inject, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command'; import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { AccountSequence } from '@/domain/value-objects'; UserAccountRepository,
import { TokenService } from '@/application/services/token.service'; USER_ACCOUNT_REPOSITORY,
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; } from '@/domain/repositories/user-account.repository.interface';
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service'; import { AccountSequence } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { TokenService } from '@/application/services/token.service';
import { RecoverAccountResult } from '../index'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util'; import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
@Injectable() import { RecoverAccountResult } from '../index';
export class RecoverByMnemonicHandler { import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
@Injectable()
constructor( export class RecoverByMnemonicHandler {
@Inject(USER_ACCOUNT_REPOSITORY) private readonly logger = new Logger(RecoverByMnemonicHandler.name);
private readonly userRepository: UserAccountRepository,
private readonly tokenService: TokenService, constructor(
private readonly eventPublisher: EventPublisherService, @Inject(USER_ACCOUNT_REPOSITORY)
private readonly blockchainClient: BlockchainClientService, private readonly userRepository: UserAccountRepository,
) {} private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> { private readonly blockchainClient: BlockchainClientService,
const accountSequence = AccountSequence.create(command.accountSequence); ) {}
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在'); async execute(
if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); command: RecoverByMnemonicCommand,
): Promise<RecoverAccountResult> {
// 调用 blockchain-service 验证助记词blockchain-service 内部查询哈希并验证) const accountSequence = AccountSequence.create(command.accountSequence);
this.logger.log(`Verifying mnemonic for account ${command.accountSequence}`); const account =
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({ await this.userRepository.findByAccountSequence(accountSequence);
accountSequence: command.accountSequence, if (!account) throw new ApplicationError('账户序列号不存在');
mnemonic: command.mnemonic, if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
});
// 调用 blockchain-service 验证助记词blockchain-service 内部查询哈希并验证)
if (!verifyResult.valid) { this.logger.log(
this.logger.warn(`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`); `Verifying mnemonic for account ${command.accountSequence}`,
throw new ApplicationError(verifyResult.message || '助记词错误'); );
} const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
accountSequence: command.accountSequence,
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`); mnemonic: command.mnemonic,
});
// 如果头像为空,重新生成一个
let avatarUrl = account.avatarUrl; if (!verifyResult.valid) {
this.logger.log(`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`); this.logger.warn(
if (avatarUrl) { `Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`,
this.logger.log(`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`); );
} throw new ApplicationError(verifyResult.message || '助记词错误');
if (!avatarUrl) { }
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
avatarUrl = generateRandomAvatarSvg(); this.logger.log(
account.updateProfile({ avatarUrl }); `Mnemonic verified successfully for account ${command.accountSequence}`,
} );
account.addDevice(command.newDeviceId, command.deviceName); // 如果头像为空,重新生成一个
account.recordLogin(); let avatarUrl = account.avatarUrl;
await this.userRepository.save(account); this.logger.log(
`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`,
const tokens = await this.tokenService.generateTokenPair({ );
userId: account.userId.toString(), if (avatarUrl) {
accountSequence: account.accountSequence.value, this.logger.log(
deviceId: command.newDeviceId, `Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`,
}); );
}
await this.eventPublisher.publishAll(account.domainEvents); if (!avatarUrl) {
account.clearDomainEvents(); this.logger.log(
`Account ${command.accountSequence} has no avatar, generating new one`,
const result = { );
userId: account.userId.toString(), avatarUrl = generateRandomAvatarSvg();
accountSequence: account.accountSequence.value, account.updateProfile({ avatarUrl });
nickname: account.nickname, }
avatarUrl,
referralCode: account.referralCode.value, account.addDevice(command.newDeviceId, command.deviceName);
accessToken: tokens.accessToken, account.recordLogin();
refreshToken: tokens.refreshToken, await this.userRepository.save(account);
};
const tokens = await this.tokenService.generateTokenPair({
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`); userId: account.userId.toString(),
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`); accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
return result; });
}
} await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
const result = {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
this.logger.log(
`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
);
this.logger.log(
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
);
return result;
}
}

View File

@ -1,9 +1,9 @@
export class RecoverByPhoneCommand { export class RecoverByPhoneCommand {
constructor( constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly phoneNumber: string, public readonly phoneNumber: string,
public readonly smsCode: string, public readonly smsCode: string,
public readonly newDeviceId: string, public readonly newDeviceId: string,
public readonly deviceName?: string, public readonly deviceName?: string,
) {} ) {}
} }

View File

@ -1,69 +1,80 @@
import { Injectable, Inject, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { RecoverByPhoneCommand } from './recover-by-phone.command'; import { RecoverByPhoneCommand } from './recover-by-phone.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { AccountSequence, PhoneNumber } from '@/domain/value-objects'; UserAccountRepository,
import { TokenService } from '@/application/services/token.service'; USER_ACCOUNT_REPOSITORY,
import { RedisService } from '@/infrastructure/redis/redis.service'; } from '@/domain/repositories/user-account.repository.interface';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { TokenService } from '@/application/services/token.service';
import { RecoverAccountResult } from '../index'; import { RedisService } from '@/infrastructure/redis/redis.service';
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
@Injectable() import { RecoverAccountResult } from '../index';
export class RecoverByPhoneHandler { import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
private readonly logger = new Logger(RecoverByPhoneHandler.name);
@Injectable()
constructor( export class RecoverByPhoneHandler {
@Inject(USER_ACCOUNT_REPOSITORY) private readonly logger = new Logger(RecoverByPhoneHandler.name);
private readonly userRepository: UserAccountRepository,
private readonly tokenService: TokenService, constructor(
private readonly redisService: RedisService, @Inject(USER_ACCOUNT_REPOSITORY)
private readonly eventPublisher: EventPublisherService, private readonly userRepository: UserAccountRepository,
) {} private readonly tokenService: TokenService,
private readonly redisService: RedisService,
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> { private readonly eventPublisher: EventPublisherService,
const accountSequence = AccountSequence.create(command.accountSequence); ) {}
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在'); async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); const accountSequence = AccountSequence.create(command.accountSequence);
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复'); const account =
await this.userRepository.findByAccountSequence(accountSequence);
const phoneNumber = PhoneNumber.create(command.phoneNumber); if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配'); if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
if (!account.phoneNumber)
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`); throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const phoneNumber = PhoneNumber.create(command.phoneNumber);
// 如果头像为空,重新生成一个 if (!account.phoneNumber.equals(phoneNumber))
let avatarUrl = account.avatarUrl; throw new ApplicationError('手机号与账户不匹配');
if (!avatarUrl) {
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`); const cachedCode = await this.redisService.get(
avatarUrl = generateRandomAvatarSvg(); `sms:recover:${phoneNumber.value}`,
account.updateProfile({ avatarUrl }); );
} if (cachedCode !== command.smsCode)
throw new ApplicationError('验证码错误或已过期');
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin(); // 如果头像为空,重新生成一个
await this.userRepository.save(account); let avatarUrl = account.avatarUrl;
await this.redisService.delete(`sms:recover:${phoneNumber.value}`); if (!avatarUrl) {
this.logger.log(
const tokens = await this.tokenService.generateTokenPair({ `Account ${command.accountSequence} has no avatar, generating new one`,
userId: account.userId.toString(), );
accountSequence: account.accountSequence.value, avatarUrl = generateRandomAvatarSvg();
deviceId: command.newDeviceId, account.updateProfile({ avatarUrl });
}); }
await this.eventPublisher.publishAll(account.domainEvents); account.addDevice(command.newDeviceId, command.deviceName);
account.clearDomainEvents(); account.recordLogin();
await this.userRepository.save(account);
return { await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
userId: account.userId.toString(),
accountSequence: account.accountSequence.value, const tokens = await this.tokenService.generateTokenPair({
nickname: account.nickname, userId: account.userId.toString(),
avatarUrl, accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value, deviceId: command.newDeviceId,
accessToken: tokens.accessToken, });
refreshToken: tokens.refreshToken,
}; await this.eventPublisher.publishAll(account.domainEvents);
} account.clearDomainEvents();
}
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
}

View File

@ -11,7 +11,10 @@
*/ */
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, UserId } from '@/domain/value-objects'; import { ChainType, UserId } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
@ -31,7 +34,7 @@ interface WalletCompletedStatusData {
userId: string; userId: string;
publicKey?: string; publicKey?: string;
walletAddresses?: { chainType: string; address: string }[]; walletAddresses?: { chainType: string; address: string }[];
mnemonic?: string; // 恢复助记词 (明文,仅首次) mnemonic?: string; // 恢复助记词 (明文,仅首次)
updatedAt: string; updatedAt: string;
} }
@ -49,8 +52,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
// Register event handler // Register event handler
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this)); this.blockchainEventConsumer.onWalletAddressCreated(
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events'); this.handleWalletAddressCreated.bind(this),
);
this.logger.log(
'[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events',
);
} }
/** /**
@ -61,21 +68,36 @@ export class BlockchainWalletHandler implements OnModuleInit {
* - DST: dst1... (Cosmos bech32) * - DST: dst1... (Cosmos bech32)
* - BSC: 0x... (EVM) * - BSC: 0x... (EVM)
*/ */
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> { private async handleWalletAddressCreated(
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload; payload: WalletAddressCreatedPayload,
): Promise<void> {
const {
userId,
publicKey,
addresses,
mnemonic,
encryptedMnemonic,
mnemonicHash,
} = payload;
this.logger.log(`[HANDLE] Processing WalletAddressCreated: userId=${userId}`); this.logger.log(
`[HANDLE] Processing WalletAddressCreated: userId=${userId}`,
);
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`); this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`); this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`); this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
if (!userId) { if (!userId) {
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping'); this.logger.error(
'[ERROR] WalletAddressCreated event missing userId, skipping',
);
return; return;
} }
if (!addresses || addresses.length === 0) { if (!addresses || addresses.length === 0) {
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping'); this.logger.error(
'[ERROR] WalletAddressCreated event missing addresses, skipping',
);
return; return;
} }
@ -90,23 +112,29 @@ export class BlockchainWalletHandler implements OnModuleInit {
// 2. Create wallet addresses for each chain (with publicKey) // 2. Create wallet addresses for each chain (with publicKey)
const wallets: WalletAddress[] = addresses.map((addr) => { const wallets: WalletAddress[] = addresses.map((addr) => {
const chainType = this.parseChainType(addr.chainType); const chainType = this.parseChainType(addr.chainType);
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`); this.logger.log(
`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`,
);
return WalletAddress.create({ return WalletAddress.create({
userId: account.userId, userId: account.userId,
chainType, chainType,
address: addr.address, address: addr.address,
publicKey, // 传入公钥,用于关联助记词 publicKey, // 传入公钥,用于关联助记词
}); });
}); });
// 3. Save wallet addresses to user account // 3. Save wallet addresses to user account
await this.userRepository.saveWallets(account.userId, wallets); await this.userRepository.saveWallets(account.userId, wallets);
this.logger.log(`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`); this.logger.log(
`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`,
);
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation) // 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
// Note: blockchain-service stores mnemonic with accountSequence association // Note: blockchain-service stores mnemonic with accountSequence association
if (mnemonic) { if (mnemonic) {
this.logger.log(`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`); this.logger.log(
`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`,
);
} }
// 5. Update Redis status to completed (include mnemonic for first-time retrieval) // 5. Update Redis status to completed (include mnemonic for first-time retrieval)
@ -116,7 +144,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
userId, userId,
publicKey, publicKey,
walletAddresses: addresses, walletAddresses: addresses,
mnemonic, // 首次返回明文助记词 mnemonic, // 首次返回明文助记词
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@ -128,9 +156,13 @@ export class BlockchainWalletHandler implements OnModuleInit {
); );
if (updated) { if (updated) {
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`); this.logger.log(
`[STATUS] Keygen status updated to 'completed' for user: ${userId}`,
);
} else { } else {
this.logger.log(`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`); this.logger.log(
`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`,
);
} }
// Log all addresses // Log all addresses
@ -138,7 +170,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`); this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
}); });
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to process WalletAddressCreated: ${error}`, error); this.logger.error(
`[ERROR] Failed to process WalletAddressCreated: ${error}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
// This ensures messages are not marked as consumed until successfully processed // This ensures messages are not marked as consumed until successfully processed
throw error; throw error;
@ -158,9 +193,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
case 'BSC': case 'BSC':
return ChainType.BSC; return ChainType.BSC;
default: default:
this.logger.warn(`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`); this.logger.warn(
`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`,
);
return ChainType.BSC; return ChainType.BSC;
} }
} }
} }

View File

@ -25,7 +25,12 @@ import {
const KEYGEN_STATUS_PREFIX = 'keygen:status:'; const KEYGEN_STATUS_PREFIX = 'keygen:status:';
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
export type KeygenStatus = 'pending' | 'generating' | 'deriving' | 'completed' | 'failed'; export type KeygenStatus =
| 'pending'
| 'generating'
| 'deriving'
| 'completed'
| 'failed';
export interface KeygenStatusData { export interface KeygenStatusData {
status: KeygenStatus; status: KeygenStatus;
@ -48,9 +53,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
// Register event handlers // Register event handlers
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this)); this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this)); this.mpcEventConsumer.onKeygenCompleted(
this.handleKeygenCompleted.bind(this),
);
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this)); this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
this.logger.log('[INIT] Registered MPC event handlers (status updates only)'); this.logger.log(
'[INIT] Registered MPC event handlers (status updates only)',
);
} }
/** /**
@ -58,9 +67,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* *
* Update Redis status to "generating" * Update Redis status to "generating"
*/ */
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> { private async handleKeygenStarted(
payload: KeygenStartedPayload,
): Promise<void> {
const { userId, mpcSessionId } = payload; const { userId, mpcSessionId } = payload;
this.logger.log(`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`); this.logger.log(
`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`,
);
try { try {
const statusData: KeygenStatusData = { const statusData: KeygenStatusData = {
@ -76,9 +89,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
KEYGEN_STATUS_TTL, KEYGEN_STATUS_TTL,
); );
this.logger.log(`[STATUS] Keygen status updated to 'generating' for user: ${userId}`); this.logger.log(
`[STATUS] Keygen status updated to 'generating' for user: ${userId}`,
);
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error); this.logger.error(
`[ERROR] Failed to update keygen status: ${error}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
throw error; throw error;
} }
@ -94,7 +112,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* Uses atomic Redis update to ensure status only advances forward: * Uses atomic Redis update to ensure status only advances forward:
* pending -> generating -> deriving -> completed * pending -> generating -> deriving -> completed
*/ */
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> { private async handleKeygenCompleted(
payload: KeygenCompletedPayload,
): Promise<void> {
const { publicKey, extraPayload } = payload; const { publicKey, extraPayload } = payload;
if (!extraPayload?.userId) { if (!extraPayload?.userId) {
@ -103,11 +123,15 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
} }
const { userId, username } = extraPayload; const { userId, username } = extraPayload;
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`); this.logger.log(
`[STATUS] Keygen completed: userId=${userId}, username=${username}`,
);
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`); this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
try { try {
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`); this.logger.log(
`[STATUS] Waiting for blockchain-service to derive addresses...`,
);
// Update status to "deriving" - waiting for blockchain-service // Update status to "deriving" - waiting for blockchain-service
// Uses atomic operation to ensure we don't overwrite higher-priority status // Uses atomic operation to ensure we don't overwrite higher-priority status
@ -126,13 +150,22 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
); );
if (updated) { if (updated) {
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`); this.logger.log(
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`); `[STATUS] Keygen status updated to 'deriving' for user: ${userId}`,
);
this.logger.log(
`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`,
);
} else { } else {
this.logger.log(`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`); this.logger.log(
`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`,
);
} }
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error); this.logger.error(
`[ERROR] Failed to update keygen status: ${error}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
throw error; throw error;
} }
@ -145,7 +178,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* 1. Log error * 1. Log error
* 2. Update Redis status to "failed" * 2. Update Redis status to "failed"
*/ */
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> { private async handleSessionFailed(
payload: SessionFailedPayload,
): Promise<void> {
const { sessionType, errorMessage, extraPayload } = payload; const { sessionType, errorMessage, extraPayload } = payload;
// Only handle keygen failures // Only handle keygen failures
@ -154,7 +189,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
} }
const userId = extraPayload?.userId || 'unknown'; const userId = extraPayload?.userId || 'unknown';
this.logger.error(`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`); this.logger.error(
`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`,
);
try { try {
// Update Redis status to failed // Update Redis status to failed
@ -171,9 +208,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
KEYGEN_STATUS_TTL, KEYGEN_STATUS_TTL,
); );
this.logger.log(`[STATUS] Keygen status updated to 'failed' for user: ${userId}`); this.logger.log(
`[STATUS] Keygen status updated to 'failed' for user: ${userId}`,
);
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to update keygen failed status: ${error}`, error); this.logger.error(
`[ERROR] Failed to update keygen failed status: ${error}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
throw error; throw error;
} }

View File

@ -1,27 +1,32 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { GetMyDevicesQuery } from './get-my-devices.query'; import { GetMyDevicesQuery } from './get-my-devices.query';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { UserId } from '@/domain/value-objects'; UserAccountRepository,
import { ApplicationError } from '@/shared/exceptions/domain.exception'; USER_ACCOUNT_REPOSITORY,
import { DeviceDTO } from '@/application/commands'; } from '@/domain/repositories/user-account.repository.interface';
import { UserId } from '@/domain/value-objects';
@Injectable() import { ApplicationError } from '@/shared/exceptions/domain.exception';
export class GetMyDevicesHandler { import { DeviceDTO } from '@/application/commands';
constructor(
@Inject(USER_ACCOUNT_REPOSITORY) @Injectable()
private readonly userRepository: UserAccountRepository, export class GetMyDevicesHandler {
) {} constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> { private readonly userRepository: UserAccountRepository,
const account = await this.userRepository.findById(UserId.create(query.userId)); ) {}
if (!account) throw new ApplicationError('用户不存在');
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
return account.getAllDevices().map((device) => ({ const account = await this.userRepository.findById(
deviceId: device.deviceId, UserId.create(query.userId),
deviceName: device.deviceName, );
addedAt: device.addedAt, if (!account) throw new ApplicationError('用户不存在');
lastActiveAt: device.lastActiveAt,
isCurrent: device.deviceId === query.currentDeviceId, return account.getAllDevices().map((device) => ({
})); deviceId: device.deviceId,
} deviceName: device.deviceName,
} addedAt: device.addedAt,
lastActiveAt: device.lastActiveAt,
isCurrent: device.deviceId === query.currentDeviceId,
}));
}
}

View File

@ -1,6 +1,6 @@
export class GetMyDevicesQuery { export class GetMyDevicesQuery {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly currentDeviceId: string, public readonly currentDeviceId: string,
) {} ) {}
} }

View File

@ -1,43 +1,51 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { GetMyProfileQuery } from './get-my-profile.query'; import { GetMyProfileQuery } from './get-my-profile.query';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; UserAccountRepository,
import { UserId } from '@/domain/value-objects'; USER_ACCOUNT_REPOSITORY,
import { ApplicationError } from '@/shared/exceptions/domain.exception'; } from '@/domain/repositories/user-account.repository.interface';
import { UserProfileDTO } from '@/application/commands'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { UserId } from '@/domain/value-objects';
@Injectable() import { ApplicationError } from '@/shared/exceptions/domain.exception';
export class GetMyProfileHandler { import { UserProfileDTO } from '@/application/commands';
constructor(
@Inject(USER_ACCOUNT_REPOSITORY) @Injectable()
private readonly userRepository: UserAccountRepository, export class GetMyProfileHandler {
) {} constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> { private readonly userRepository: UserAccountRepository,
const account = await this.userRepository.findById(UserId.create(query.userId)); ) {}
if (!account) throw new ApplicationError('用户不存在');
return this.toDTO(account); async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
} const account = await this.userRepository.findById(
UserId.create(query.userId),
private toDTO(account: UserAccount): UserProfileDTO { );
return { if (!account) throw new ApplicationError('用户不存在');
userId: account.userId.toString(), return this.toDTO(account);
accountSequence: account.accountSequence.value, }
phoneNumber: account.phoneNumber?.masked() || null,
nickname: account.nickname, private toDTO(account: UserAccount): UserProfileDTO {
avatarUrl: account.avatarUrl, return {
referralCode: account.referralCode.value, userId: account.userId.toString(),
walletAddresses: account.getAllWalletAddresses().map((wa) => ({ accountSequence: account.accountSequence.value,
chainType: wa.chainType, phoneNumber: account.phoneNumber?.masked() || null,
address: wa.address, nickname: account.nickname,
})), avatarUrl: account.avatarUrl,
kycStatus: account.kycStatus, referralCode: account.referralCode.value,
kycInfo: account.kycInfo walletAddresses: account.getAllWalletAddresses().map((wa) => ({
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() } chainType: wa.chainType,
: null, address: wa.address,
status: account.status, })),
registeredAt: account.registeredAt, kycStatus: account.kycStatus,
lastLoginAt: account.lastLoginAt, kycInfo: account.kycInfo
}; ? {
} realName: account.kycInfo.realName,
} idCardNumber: account.kycInfo.maskedIdCardNumber(),
}
: null,
status: account.status,
registeredAt: account.registeredAt,
lastLoginAt: account.lastLoginAt,
};
}
}

View File

@ -1,3 +1,3 @@
export class GetMyProfileQuery { export class GetMyProfileQuery {
constructor(public readonly userId: string) {} constructor(public readonly userId: string) {}
} }

View File

@ -1,93 +1,103 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { ApplicationError } from '@/shared/exceptions/domain.exception';
export interface TokenPayload { export interface TokenPayload {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
type: 'access' | 'refresh'; type: 'access' | 'refresh';
} }
@Injectable() @Injectable()
export class TokenService { export class TokenService {
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
async generateTokenPair(payload: { async generateTokenPair(payload: {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
}): Promise<{ accessToken: string; refreshToken: string }> { }): Promise<{ accessToken: string; refreshToken: string }> {
const accessToken = this.jwtService.sign( const accessToken = this.jwtService.sign(
{ ...payload, type: 'access' }, { ...payload, type: 'access' },
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') }, {
); expiresIn: this.configService.get<string>(
'JWT_ACCESS_EXPIRES_IN',
const refreshToken = this.jwtService.sign( '2h',
{ ...payload, type: 'refresh' }, ),
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') }, },
); );
// Save refresh token hash const refreshToken = this.jwtService.sign(
const tokenHash = this.hashToken(refreshToken); { ...payload, type: 'refresh' },
await this.prisma.deviceToken.create({ {
data: { expiresIn: this.configService.get<string>(
userId: BigInt(payload.userId), 'JWT_REFRESH_EXPIRES_IN',
deviceId: payload.deviceId, '30d',
refreshTokenHash: tokenHash, ),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), },
}, );
});
// Save refresh token hash
return { accessToken, refreshToken }; const tokenHash = this.hashToken(refreshToken);
} await this.prisma.deviceToken.create({
data: {
async verifyRefreshToken(token: string): Promise<{ userId: BigInt(payload.userId),
userId: string; deviceId: payload.deviceId,
accountSequence: string; refreshTokenHash: tokenHash,
deviceId: string; expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}> { },
try { });
const payload = this.jwtService.verify<TokenPayload>(token);
if (payload.type !== 'refresh') { return { accessToken, refreshToken };
throw new ApplicationError('无效的RefreshToken'); }
}
async verifyRefreshToken(token: string): Promise<{
const tokenHash = this.hashToken(token); userId: string;
const storedToken = await this.prisma.deviceToken.findUnique({ accountSequence: string;
where: { refreshTokenHash: tokenHash }, deviceId: string;
}); }> {
try {
if (!storedToken || storedToken.revokedAt) { const payload = this.jwtService.verify<TokenPayload>(token);
throw new ApplicationError('RefreshToken已失效'); if (payload.type !== 'refresh') {
} throw new ApplicationError('无效的RefreshToken');
}
return {
userId: payload.userId, const tokenHash = this.hashToken(token);
accountSequence: payload.accountSequence, const storedToken = await this.prisma.deviceToken.findUnique({
deviceId: payload.deviceId, where: { refreshTokenHash: tokenHash },
}; });
} catch (error) {
if (error instanceof ApplicationError) throw error; if (!storedToken || storedToken.revokedAt) {
throw new ApplicationError('RefreshToken已过期或无效'); throw new ApplicationError('RefreshToken已失效');
} }
}
return {
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> { userId: payload.userId,
await this.prisma.deviceToken.updateMany({ accountSequence: payload.accountSequence,
where: { userId: BigInt(userId), deviceId, revokedAt: null }, deviceId: payload.deviceId,
data: { revokedAt: new Date() }, };
}); } catch (error) {
} if (error instanceof ApplicationError) throw error;
throw new ApplicationError('RefreshToken已过期或无效');
private hashToken(token: string): string { }
return createHash('sha256').update(token).digest('hex'); }
}
} async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
await this.prisma.deviceToken.updateMany({
where: { userId: BigInt(userId), deviceId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}

View File

@ -11,13 +11,14 @@ export class TotpService {
private readonly logger = new Logger(TotpService.name); private readonly logger = new Logger(TotpService.name);
// TOTP 配置 // TOTP 配置
private readonly TOTP_DIGITS = 6; // 验证码位数 private readonly TOTP_DIGITS = 6; // 验证码位数
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒) private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移 private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
private readonly ISSUER = 'RWADurian'; // 应用名称 private readonly ISSUER = 'RWADurian'; // 应用名称
// AES 加密密钥 (生产环境应从环境变量获取) // AES 加密密钥 (生产环境应从环境变量获取)
private readonly ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch'; private readonly ENCRYPTION_KEY =
process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}

View File

@ -19,7 +19,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { UserId } from '@/domain/value-objects'; import { UserId } from '@/domain/value-objects';
@ -64,7 +67,9 @@ export class WalletRetryTask {
async handleWalletRetry() { async handleWalletRetry() {
// 防止并发执行 // 防止并发执行
if (this.isRunning) { if (this.isRunning) {
this.logger.warn('[TASK] Previous task still running, skipping this execution'); this.logger.warn(
'[TASK] Previous task still running, skipping this execution',
);
return; return;
} }
@ -73,8 +78,12 @@ export class WalletRetryTask {
try { try {
// 1. 扫描所有 keygen:status:* keys // 1. 扫描所有 keygen:status:* keys
const statusKeys = await this.redisService.keys(`${KEYGEN_STATUS_PREFIX}*`); const statusKeys = await this.redisService.keys(
this.logger.log(`[TASK] Found ${statusKeys.length} wallet generation records`); `${KEYGEN_STATUS_PREFIX}*`,
);
this.logger.log(
`[TASK] Found ${statusKeys.length} wallet generation records`,
);
for (const key of statusKeys) { for (const key of statusKeys) {
try { try {
@ -123,7 +132,9 @@ export class WalletRetryTask {
// 检查重试限制 // 检查重试限制
const canRetry = await this.checkRetryLimit(userId); const canRetry = await this.checkRetryLimit(userId);
if (!canRetry) { if (!canRetry) {
this.logger.warn(`[TASK] User ${userId} exceeded retry time limit (10 minutes)`); this.logger.warn(
`[TASK] User ${userId} exceeded retry time limit (10 minutes)`,
);
// 更新状态为最终失败 // 更新状态为最终失败
await this.markAsFinalFailure(userId); await this.markAsFinalFailure(userId);
return; return;
@ -144,19 +155,25 @@ export class WalletRetryTask {
// 情况1状态为 failed // 情况1状态为 failed
if (currentStatus === 'failed') { if (currentStatus === 'failed') {
this.logger.log(`[TASK] User ${status.userId} status is 'failed', will retry`); this.logger.log(
`[TASK] User ${status.userId} status is 'failed', will retry`,
);
return true; return true;
} }
// 情况2状态为 generating 但超过 60 秒 // 情况2状态为 generating 但超过 60 秒
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) { if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
this.logger.log(`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`); this.logger.log(
`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`,
);
return true; return true;
} }
// 情况3状态为 deriving 但超过 60 秒 // 情况3状态为 deriving 但超过 60 秒
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) { if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
this.logger.log(`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`); this.logger.log(
`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`,
);
return true; return true;
} }
@ -189,7 +206,9 @@ export class WalletRetryTask {
// 如果超过 10 分钟,不再重试 // 如果超过 10 分钟,不再重试
if (elapsed > MAX_RETRY_DURATION_MS) { if (elapsed > MAX_RETRY_DURATION_MS) {
this.logger.warn(`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`); this.logger.warn(
`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`,
);
return false; return false;
} }
@ -223,7 +242,9 @@ export class WalletRetryTask {
await this.eventPublisher.publish(event); await this.eventPublisher.publish(event);
this.logger.log(`[TASK] Wallet generation retry triggered for user: ${userId}`); this.logger.log(
`[TASK] Wallet generation retry triggered for user: ${userId}`,
);
// 4. 更新 Redis 状态为 pending等待重新生成 // 4. 更新 Redis 状态为 pending等待重新生成
const statusData: KeygenStatusData = { const statusData: KeygenStatusData = {
@ -238,7 +259,10 @@ export class WalletRetryTask {
60 * 60 * 24, // 24 小时 60 * 60 * 24, // 24 小时
); );
} catch (error) { } catch (error) {
this.logger.error(`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`, error); this.logger.error(
`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`,
error,
);
} }
} }
@ -274,7 +298,9 @@ export class WalletRetryTask {
60 * 60 * 24, // 24 小时 60 * 60 * 24, // 24 小时
); );
this.logger.log(`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`); this.logger.log(
`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`,
);
} }
/** /**
@ -294,6 +320,8 @@ export class WalletRetryTask {
60 * 60 * 24, // 24 小时 60 * 60 * 24, // 24 小时
); );
this.logger.error(`[TASK] Marked user ${userId} as final failure after retry timeout`); this.logger.error(
`[TASK] Marked user ${userId} as final failure after retry timeout`,
);
} }
} }

View File

@ -1,4 +1,4 @@
export const appConfig = () => ({ export const appConfig = () => ({
port: parseInt(process.env.APP_PORT || '3000', 10), port: parseInt(process.env.APP_PORT || '3000', 10),
env: process.env.APP_ENV || 'development', env: process.env.APP_ENV || 'development',
}); });

View File

@ -1,3 +1,3 @@
export const databaseConfig = () => ({ export const databaseConfig = () => ({
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
}); });

View File

@ -1,44 +1,44 @@
export const appConfig = () => ({ export const appConfig = () => ({
port: parseInt(process.env.APP_PORT || '3000', 10), port: parseInt(process.env.APP_PORT || '3000', 10),
env: process.env.APP_ENV || 'development', env: process.env.APP_ENV || 'development',
}); });
export const databaseConfig = () => ({ export const databaseConfig = () => ({
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
}); });
export const jwtConfig = () => ({ export const jwtConfig = () => ({
secret: process.env.JWT_SECRET || 'default-secret', secret: process.env.JWT_SECRET || 'default-secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
}); });
export const redisConfig = () => ({ export const redisConfig = () => ({
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined, password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10), db: parseInt(process.env.REDIS_DB || '0', 10),
}); });
export const kafkaConfig = () => ({ export const kafkaConfig = () => ({
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service', clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group', groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
}); });
export const smsConfig = () => ({ export const smsConfig = () => ({
// 阿里云 SMS 配置 // 阿里云 SMS 配置
aliyun: { aliyun: {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '', accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '', accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后', signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '', templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com', endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
}, },
// 是否启用真实发送(开发环境可关闭) // 是否启用真实发送(开发环境可关闭)
enabled: process.env.SMS_ENABLED === 'true', enabled: process.env.SMS_ENABLED === 'true',
}); });
export const walletConfig = () => ({ export const walletConfig = () => ({
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt', encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
}); });

View File

@ -1,5 +1,5 @@
export const jwtConfig = () => ({ export const jwtConfig = () => ({
secret: process.env.JWT_SECRET || 'default-secret', secret: process.env.JWT_SECRET || 'default-secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
}); });

View File

@ -1,5 +1,5 @@
export const kafkaConfig = () => ({ export const kafkaConfig = () => ({
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service', clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group', groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
}); });

View File

@ -1,6 +1,6 @@
export const redisConfig = () => ({ export const redisConfig = () => ({
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined, password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10), db: parseInt(process.env.REDIS_DB || '0', 10),
}); });

View File

@ -1 +1 @@
export * from './user-account.aggregate'; export * from './user-account.aggregate';

View File

@ -1,356 +1,524 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
import { import {
UserId, AccountSequence, PhoneNumber, ReferralCode, UserId,
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus, AccountSequence,
} from '@/domain/value-objects'; PhoneNumber,
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; ReferralCode,
import { DeviceInfo,
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent, ChainType,
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent, KYCInfo,
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent, KYCStatus,
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent, AccountStatus,
UserAccountFrozenEvent, UserAccountDeactivatedEvent, } from '@/domain/value-objects';
} from '@/domain/events'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
export class UserAccount { DomainEvent,
private readonly _userId: UserId; UserAccountAutoCreatedEvent,
private readonly _accountSequence: AccountSequence; UserAccountCreatedEvent,
private _devices: Map<string, DeviceInfo>; DeviceAddedEvent,
private _phoneNumber: PhoneNumber | null; DeviceRemovedEvent,
private _nickname: string; PhoneNumberBoundEvent,
private _avatarUrl: string | null; WalletAddressBoundEvent,
private readonly _inviterSequence: AccountSequence | null; MultipleWalletAddressesBoundEvent,
private readonly _referralCode: ReferralCode; KYCSubmittedEvent,
private _walletAddresses: Map<ChainType, WalletAddress>; KYCVerifiedEvent,
private _kycInfo: KYCInfo | null; KYCRejectedEvent,
private _kycStatus: KYCStatus; UserAccountFrozenEvent,
private _status: AccountStatus; UserAccountDeactivatedEvent,
private readonly _registeredAt: Date; } from '@/domain/events';
private _lastLoginAt: Date | null;
private _updatedAt: Date; export class UserAccount {
private _domainEvents: DomainEvent[] = []; private readonly _userId: UserId;
private readonly _accountSequence: AccountSequence;
// Getters private _devices: Map<string, DeviceInfo>;
get userId(): UserId { return this._userId; } private _phoneNumber: PhoneNumber | null;
get accountSequence(): AccountSequence { return this._accountSequence; } private _nickname: string;
get phoneNumber(): PhoneNumber | null { return this._phoneNumber; } private _avatarUrl: string | null;
get nickname(): string { return this._nickname; } private readonly _inviterSequence: AccountSequence | null;
get avatarUrl(): string | null { return this._avatarUrl; } private readonly _referralCode: ReferralCode;
get inviterSequence(): AccountSequence | null { return this._inviterSequence; } private _walletAddresses: Map<ChainType, WalletAddress>;
get referralCode(): ReferralCode { return this._referralCode; } private _kycInfo: KYCInfo | null;
get kycInfo(): KYCInfo | null { return this._kycInfo; } private _kycStatus: KYCStatus;
get kycStatus(): KYCStatus { return this._kycStatus; } private _status: AccountStatus;
get status(): AccountStatus { return this._status; } private readonly _registeredAt: Date;
get registeredAt(): Date { return this._registeredAt; } private _lastLoginAt: Date | null;
get lastLoginAt(): Date | null { return this._lastLoginAt; } private _updatedAt: Date;
get updatedAt(): Date { return this._updatedAt; } private _domainEvents: DomainEvent[] = [];
get isActive(): boolean { return this._status === AccountStatus.ACTIVE; }
get isKYCVerified(): boolean { return this._kycStatus === KYCStatus.VERIFIED; } // Getters
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; } get userId(): UserId {
return this._userId;
private constructor( }
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>, get accountSequence(): AccountSequence {
phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null, return this._accountSequence;
inviterSequence: AccountSequence | null, referralCode: ReferralCode, }
walletAddresses: Map<ChainType, WalletAddress>, kycInfo: KYCInfo | null, get phoneNumber(): PhoneNumber | null {
kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date, return this._phoneNumber;
lastLoginAt: Date | null, updatedAt: Date, }
) { get nickname(): string {
this._userId = userId; return this._nickname;
this._accountSequence = accountSequence; }
this._devices = devices; get avatarUrl(): string | null {
this._phoneNumber = phoneNumber; return this._avatarUrl;
this._nickname = nickname; }
this._avatarUrl = avatarUrl; get inviterSequence(): AccountSequence | null {
this._inviterSequence = inviterSequence; return this._inviterSequence;
this._referralCode = referralCode; }
this._walletAddresses = walletAddresses; get referralCode(): ReferralCode {
this._kycInfo = kycInfo; return this._referralCode;
this._kycStatus = kycStatus; }
this._status = status; get kycInfo(): KYCInfo | null {
this._registeredAt = registeredAt; return this._kycInfo;
this._lastLoginAt = lastLoginAt; }
this._updatedAt = updatedAt; get kycStatus(): KYCStatus {
} return this._kycStatus;
}
static createAutomatic(params: { get status(): AccountStatus {
accountSequence: AccountSequence; return this._status;
initialDeviceId: string; }
deviceName?: string; get registeredAt(): Date {
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON return this._registeredAt;
inviterSequence: AccountSequence | null; }
nickname?: string; get lastLoginAt(): Date | null {
avatarSvg?: string; return this._lastLoginAt;
}): UserAccount { }
const devices = new Map<string, DeviceInfo>(); get updatedAt(): Date {
devices.set(params.initialDeviceId, new DeviceInfo( return this._updatedAt;
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), }
params.deviceInfo, // 传递完整的 JSON get isActive(): boolean {
)); return this._status === AccountStatus.ACTIVE;
}
// UserID将由数据库自动生成(autoincrement)这里使用临时值0 get isKYCVerified(): boolean {
const nickname = params.nickname || `用户${params.accountSequence.dailySequence}`; return this._kycStatus === KYCStatus.VERIFIED;
const avatarUrl = params.avatarSvg || null; }
get domainEvents(): DomainEvent[] {
const account = new UserAccount( return [...this._domainEvents];
UserId.create(0), params.accountSequence, devices, null, }
nickname, avatarUrl, params.inviterSequence,
ReferralCode.generate(), private constructor(
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE, userId: UserId,
new Date(), null, new Date(), accountSequence: AccountSequence,
); devices: Map<string, DeviceInfo>,
phoneNumber: PhoneNumber | null,
account.addDomainEvent(new UserAccountAutoCreatedEvent({ nickname: string,
userId: account.userId.toString(), avatarUrl: string | null,
accountSequence: params.accountSequence.value, inviterSequence: AccountSequence | null,
referralCode: account._referralCode.value, // 用户的推荐码 referralCode: ReferralCode,
initialDeviceId: params.initialDeviceId, walletAddresses: Map<ChainType, WalletAddress>,
inviterSequence: params.inviterSequence?.value || null, kycInfo: KYCInfo | null,
registeredAt: account._registeredAt, kycStatus: KYCStatus,
})); status: AccountStatus,
registeredAt: Date,
return account; lastLoginAt: Date | null,
} updatedAt: Date,
) {
static create(params: { this._userId = userId;
accountSequence: AccountSequence; this._accountSequence = accountSequence;
phoneNumber: PhoneNumber; this._devices = devices;
initialDeviceId: string; this._phoneNumber = phoneNumber;
deviceName?: string; this._nickname = nickname;
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON this._avatarUrl = avatarUrl;
inviterSequence: AccountSequence | null; this._inviterSequence = inviterSequence;
}): UserAccount { this._referralCode = referralCode;
const devices = new Map<string, DeviceInfo>(); this._walletAddresses = walletAddresses;
devices.set(params.initialDeviceId, new DeviceInfo( this._kycInfo = kycInfo;
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), this._kycStatus = kycStatus;
params.deviceInfo, this._status = status;
)); this._registeredAt = registeredAt;
this._lastLoginAt = lastLoginAt;
// UserID将由数据库自动生成(autoincrement)这里使用临时值0 this._updatedAt = updatedAt;
const account = new UserAccount( }
UserId.create(0), params.accountSequence, devices, params.phoneNumber,
`用户${params.accountSequence.dailySequence}`, null, params.inviterSequence, static createAutomatic(params: {
ReferralCode.generate(), accountSequence: AccountSequence;
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE, initialDeviceId: string;
new Date(), null, new Date(), deviceName?: string;
); deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
inviterSequence: AccountSequence | null;
account.addDomainEvent(new UserAccountCreatedEvent({ nickname?: string;
userId: account.userId.toString(), avatarSvg?: string;
accountSequence: params.accountSequence.value, }): UserAccount {
referralCode: account._referralCode.value, // 用户的推荐码 const devices = new Map<string, DeviceInfo>();
phoneNumber: params.phoneNumber.value, devices.set(
initialDeviceId: params.initialDeviceId, params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null, new DeviceInfo(
registeredAt: account._registeredAt, params.initialDeviceId,
})); params.deviceName || '未命名设备',
new Date(),
return account; new Date(),
} params.deviceInfo, // 传递完整的 JSON
),
static reconstruct(params: { );
userId: string; accountSequence: string; devices: DeviceInfo[];
phoneNumber: string | null; nickname: string; avatarUrl: string | null; // UserID将由数据库自动生成(autoincrement)这里使用临时值0
inviterSequence: string | null; referralCode: string; const nickname =
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null; params.nickname || `用户${params.accountSequence.dailySequence}`;
kycStatus: KYCStatus; status: AccountStatus; const avatarUrl = params.avatarSvg || null;
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
}): UserAccount { const account = new UserAccount(
const deviceMap = new Map<string, DeviceInfo>(); UserId.create(0),
params.devices.forEach(d => deviceMap.set(d.deviceId, d)); params.accountSequence,
devices,
const walletMap = new Map<ChainType, WalletAddress>(); null,
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w)); nickname,
avatarUrl,
return new UserAccount( params.inviterSequence,
UserId.create(params.userId), ReferralCode.generate(),
AccountSequence.create(params.accountSequence), new Map(),
deviceMap, null,
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null, KYCStatus.NOT_VERIFIED,
params.nickname, AccountStatus.ACTIVE,
params.avatarUrl, new Date(),
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null, null,
ReferralCode.create(params.referralCode), new Date(),
walletMap, );
params.kycInfo,
params.kycStatus, account.addDomainEvent(
params.status, new UserAccountAutoCreatedEvent({
params.registeredAt, userId: account.userId.toString(),
params.lastLoginAt, accountSequence: params.accountSequence.value,
params.updatedAt, referralCode: account._referralCode.value, // 用户的推荐码
); initialDeviceId: params.initialDeviceId,
} inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
addDevice(deviceId: string, deviceName?: string, deviceInfo?: Record<string, unknown>): void { }),
this.ensureActive(); );
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainError('最多允许5个设备同时登录'); return account;
} }
if (this._devices.has(deviceId)) {
const device = this._devices.get(deviceId)!; static create(params: {
device.updateActivity(); accountSequence: AccountSequence;
if (deviceInfo) { phoneNumber: PhoneNumber;
device.updateDeviceInfo(deviceInfo); initialDeviceId: string;
} deviceName?: string;
} else { deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
this._devices.set(deviceId, new DeviceInfo( inviterSequence: AccountSequence | null;
deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo, }): UserAccount {
)); const devices = new Map<string, DeviceInfo>();
this.addDomainEvent(new DeviceAddedEvent({ devices.set(
userId: this.userId.toString(), params.initialDeviceId,
accountSequence: this.accountSequence.value, new DeviceInfo(
deviceId, params.initialDeviceId,
deviceName: deviceName || '未命名设备', params.deviceName || '未命名设备',
})); new Date(),
} new Date(),
this._updatedAt = new Date(); params.deviceInfo,
} ),
);
removeDevice(deviceId: string): void {
this.ensureActive(); // UserID将由数据库自动生成(autoincrement)这里使用临时值0
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在'); const account = new UserAccount(
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备'); UserId.create(0),
this._devices.delete(deviceId); params.accountSequence,
this._updatedAt = new Date(); devices,
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId })); params.phoneNumber,
} `用户${params.accountSequence.dailySequence}`,
null,
isDeviceAuthorized(deviceId: string): boolean { params.inviterSequence,
return this._devices.has(deviceId); ReferralCode.generate(),
} new Map(),
null,
getAllDevices(): DeviceInfo[] { KYCStatus.NOT_VERIFIED,
return Array.from(this._devices.values()); AccountStatus.ACTIVE,
} new Date(),
null,
updateProfile(params: { nickname?: string; avatarUrl?: string }): void { new Date(),
this.ensureActive(); );
if (params.nickname) this._nickname = params.nickname;
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl; account.addDomainEvent(
this._updatedAt = new Date(); new UserAccountCreatedEvent({
} userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
bindPhoneNumber(phoneNumber: PhoneNumber): void { referralCode: account._referralCode.value, // 用户的推荐码
this.ensureActive(); phoneNumber: params.phoneNumber.value,
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定'); initialDeviceId: params.initialDeviceId,
this._phoneNumber = phoneNumber; inviterSequence: params.inviterSequence?.value || null,
this._updatedAt = new Date(); registeredAt: account._registeredAt,
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.toString(), phoneNumber: phoneNumber.value })); }),
} );
bindWalletAddress(chainType: ChainType, address: string): void { return account;
this.ensureActive(); }
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address }); static reconstruct(params: {
this._walletAddresses.set(chainType, walletAddress); userId: string;
this._updatedAt = new Date(); accountSequence: string;
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.toString(), chainType, address })); devices: DeviceInfo[];
} phoneNumber: string | null;
nickname: string;
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void { avatarUrl: string | null;
this.ensureActive(); inviterSequence: string | null;
for (const [chainType, wallet] of wallets) { referralCode: string;
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`); walletAddresses: WalletAddress[];
this._walletAddresses.set(chainType, wallet); kycInfo: KYCInfo | null;
} kycStatus: KYCStatus;
this._updatedAt = new Date(); status: AccountStatus;
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({ registeredAt: Date;
userId: this.userId.toString(), lastLoginAt: Date | null;
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })), updatedAt: Date;
})); }): UserAccount {
} const deviceMap = new Map<string, DeviceInfo>();
params.devices.forEach((d) => deviceMap.set(d.deviceId, d));
submitKYC(kycInfo: KYCInfo): void {
this.ensureActive(); const walletMap = new Map<ChainType, WalletAddress>();
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交'); params.walletAddresses.forEach((w) => walletMap.set(w.chainType, w));
this._kycInfo = kycInfo;
this._kycStatus = KYCStatus.PENDING; return new UserAccount(
this._updatedAt = new Date(); UserId.create(params.userId),
this.addDomainEvent(new KYCSubmittedEvent({ AccountSequence.create(params.accountSequence),
userId: this.userId.toString(), realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber, deviceMap,
})); params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
} params.nickname,
params.avatarUrl,
approveKYC(): void { params.inviterSequence
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC'); ? AccountSequence.create(params.inviterSequence)
this._kycStatus = KYCStatus.VERIFIED; : null,
this._updatedAt = new Date(); ReferralCode.create(params.referralCode),
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.toString(), verifiedAt: new Date() })); walletMap,
} params.kycInfo,
params.kycStatus,
rejectKYC(reason: string): void { params.status,
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC'); params.registeredAt,
this._kycStatus = KYCStatus.REJECTED; params.lastLoginAt,
this._updatedAt = new Date(); params.updatedAt,
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.toString(), reason })); );
} }
recordLogin(): void { addDevice(
this.ensureActive(); deviceId: string,
this._lastLoginAt = new Date(); deviceName?: string,
this._updatedAt = new Date(); deviceInfo?: Record<string, unknown>,
} ): void {
this.ensureActive();
freeze(reason: string): void { if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结'); throw new DomainError('最多允许5个设备同时登录');
this._status = AccountStatus.FROZEN; }
this._updatedAt = new Date(); if (this._devices.has(deviceId)) {
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.toString(), reason })); const device = this._devices.get(deviceId)!;
} device.updateActivity();
if (deviceInfo) {
unfreeze(): void { device.updateDeviceInfo(deviceInfo);
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结'); }
this._status = AccountStatus.ACTIVE; } else {
this._updatedAt = new Date(); this._devices.set(
} deviceId,
new DeviceInfo(
deactivate(): void { deviceId,
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销'); deviceName || '未命名设备',
this._status = AccountStatus.DEACTIVATED; new Date(),
this._updatedAt = new Date(); new Date(),
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.toString(), deactivatedAt: new Date() })); deviceInfo,
} ),
);
getWalletAddress(chainType: ChainType): WalletAddress | null { this.addDomainEvent(
return this._walletAddresses.get(chainType) || null; new DeviceAddedEvent({
} userId: this.userId.toString(),
accountSequence: this.accountSequence.value,
getAllWalletAddresses(): WalletAddress[] { deviceId,
return Array.from(this._walletAddresses.values()); deviceName: deviceName || '未命名设备',
} }),
);
private ensureActive(): void { }
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销'); this._updatedAt = new Date();
} }
private addDomainEvent(event: DomainEvent): void { removeDevice(deviceId: string): void {
this._domainEvents.push(event); this.ensureActive();
} if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
clearDomainEvents(): void { this._devices.delete(deviceId);
this._domainEvents = []; this._updatedAt = new Date();
} this.addDomainEvent(
new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }),
/** );
* }
*
* UserAccountCreatedEvent MPC isDeviceAuthorized(deviceId: string): boolean {
* return this._devices.has(deviceId);
*/ }
createWalletGenerationEvent(): UserAccountCreatedEvent {
// 获取第一个设备的信息 getAllDevices(): DeviceInfo[] {
const firstDevice = this._devices.values().next().value as DeviceInfo | undefined; return Array.from(this._devices.values());
}
return new UserAccountCreatedEvent({
userId: this._userId.toString(), updateProfile(params: { nickname?: string; avatarUrl?: string }): void {
accountSequence: this._accountSequence.value, this.ensureActive();
referralCode: this._referralCode.value, if (params.nickname) this._nickname = params.nickname;
phoneNumber: this._phoneNumber?.value || null, if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
initialDeviceId: firstDevice?.deviceId || 'retry-unknown', this._updatedAt = new Date();
deviceName: firstDevice?.deviceName || 'retry-device', }
deviceInfo: firstDevice?.deviceInfo || null,
inviterReferralCode: null, // 重试时不需要 bindPhoneNumber(phoneNumber: PhoneNumber): void {
createdAt: new Date(), this.ensureActive();
}); if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
} this._phoneNumber = phoneNumber;
} this._updatedAt = new Date();
this.addDomainEvent(
new PhoneNumberBoundEvent({
userId: this.userId.toString(),
phoneNumber: phoneNumber.value,
}),
);
}
bindWalletAddress(chainType: ChainType, address: string): void {
this.ensureActive();
if (this._walletAddresses.has(chainType))
throw new DomainError(`已绑定${chainType}地址`);
const walletAddress = WalletAddress.create({
userId: this.userId,
chainType,
address,
});
this._walletAddresses.set(chainType, walletAddress);
this._updatedAt = new Date();
this.addDomainEvent(
new WalletAddressBoundEvent({
userId: this.userId.toString(),
chainType,
address,
}),
);
}
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
this.ensureActive();
for (const [chainType, wallet] of wallets) {
if (this._walletAddresses.has(chainType))
throw new DomainError(`已绑定${chainType}地址`);
this._walletAddresses.set(chainType, wallet);
}
this._updatedAt = new Date();
this.addDomainEvent(
new MultipleWalletAddressesBoundEvent({
userId: this.userId.toString(),
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({
chainType,
address: wallet.address,
})),
}),
);
}
submitKYC(kycInfo: KYCInfo): void {
this.ensureActive();
if (this._kycStatus === KYCStatus.VERIFIED)
throw new DomainError('已通过KYC认证,不可重复提交');
this._kycInfo = kycInfo;
this._kycStatus = KYCStatus.PENDING;
this._updatedAt = new Date();
this.addDomainEvent(
new KYCSubmittedEvent({
userId: this.userId.toString(),
realName: kycInfo.realName,
idCardNumber: kycInfo.idCardNumber,
}),
);
}
approveKYC(): void {
if (this._kycStatus !== KYCStatus.PENDING)
throw new DomainError('只有待审核状态才能通过KYC');
this._kycStatus = KYCStatus.VERIFIED;
this._updatedAt = new Date();
this.addDomainEvent(
new KYCVerifiedEvent({
userId: this.userId.toString(),
verifiedAt: new Date(),
}),
);
}
rejectKYC(reason: string): void {
if (this._kycStatus !== KYCStatus.PENDING)
throw new DomainError('只有待审核状态才能拒绝KYC');
this._kycStatus = KYCStatus.REJECTED;
this._updatedAt = new Date();
this.addDomainEvent(
new KYCRejectedEvent({ userId: this.userId.toString(), reason }),
);
}
recordLogin(): void {
this.ensureActive();
this._lastLoginAt = new Date();
this._updatedAt = new Date();
}
freeze(reason: string): void {
if (this._status === AccountStatus.FROZEN)
throw new DomainError('账户已冻结');
this._status = AccountStatus.FROZEN;
this._updatedAt = new Date();
this.addDomainEvent(
new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }),
);
}
unfreeze(): void {
if (this._status !== AccountStatus.FROZEN)
throw new DomainError('账户未冻结');
this._status = AccountStatus.ACTIVE;
this._updatedAt = new Date();
}
deactivate(): void {
if (this._status === AccountStatus.DEACTIVATED)
throw new DomainError('账户已注销');
this._status = AccountStatus.DEACTIVATED;
this._updatedAt = new Date();
this.addDomainEvent(
new UserAccountDeactivatedEvent({
userId: this.userId.toString(),
deactivatedAt: new Date(),
}),
);
}
getWalletAddress(chainType: ChainType): WalletAddress | null {
return this._walletAddresses.get(chainType) || null;
}
getAllWalletAddresses(): WalletAddress[] {
return Array.from(this._walletAddresses.values());
}
private ensureActive(): void {
if (this._status !== AccountStatus.ACTIVE)
throw new DomainError('账户已冻结或注销');
}
private addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): void {
this._domainEvents = [];
}
/**
*
*
* UserAccountCreatedEvent MPC
*
*/
createWalletGenerationEvent(): UserAccountCreatedEvent {
// 获取第一个设备的信息
const firstDevice = this._devices.values().next().value as
| DeviceInfo
| undefined;
return new UserAccountCreatedEvent({
userId: this._userId.toString(),
accountSequence: this._accountSequence.value,
referralCode: this._referralCode.value,
phoneNumber: this._phoneNumber?.value || null,
initialDeviceId: firstDevice?.deviceId || 'retry-unknown',
deviceName: firstDevice?.deviceName || 'retry-device',
deviceInfo: firstDevice?.deviceInfo || null,
inviterReferralCode: null, // 重试时不需要
createdAt: new Date(),
});
}
}

View File

@ -1,25 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { UserAccount } from './user-account.aggregate'; import { UserAccount } from './user-account.aggregate';
import { AccountSequence, PhoneNumber } from '@/domain/value-objects'; import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
@Injectable() @Injectable()
export class UserAccountFactory { export class UserAccountFactory {
createAutomatic(params: { createAutomatic(params: {
accountSequence: AccountSequence; accountSequence: AccountSequence;
initialDeviceId: string; initialDeviceId: string;
deviceName?: string; deviceName?: string;
inviterSequence: AccountSequence | null; inviterSequence: AccountSequence | null;
}): UserAccount { }): UserAccount {
return UserAccount.createAutomatic(params); return UserAccount.createAutomatic(params);
} }
create(params: { create(params: {
accountSequence: AccountSequence; accountSequence: AccountSequence;
phoneNumber: PhoneNumber; phoneNumber: PhoneNumber;
initialDeviceId: string; initialDeviceId: string;
deviceName?: string; deviceName?: string;
inviterSequence: AccountSequence | null; inviterSequence: AccountSequence | null;
}): UserAccount { }): UserAccount {
return UserAccount.create(params); return UserAccount.create(params);
} }
} }

View File

@ -1,23 +1,26 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountSequenceGeneratorService, UserValidatorService } from './services'; import {
import { UserAccountFactory } from './aggregates/user-account/user-account.factory'; AccountSequenceGeneratorService,
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface'; UserValidatorService,
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl'; } from './services';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
@Module({ import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
imports: [InfrastructureModule], import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
providers: [
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl }, @Module({
AccountSequenceGeneratorService, imports: [InfrastructureModule],
UserValidatorService, providers: [
UserAccountFactory, { provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
], AccountSequenceGeneratorService,
exports: [ UserValidatorService,
USER_ACCOUNT_REPOSITORY, UserAccountFactory,
AccountSequenceGeneratorService, ],
UserValidatorService, exports: [
UserAccountFactory, USER_ACCOUNT_REPOSITORY,
], AccountSequenceGeneratorService,
}) UserValidatorService,
export class DomainModule {} UserAccountFactory,
],
})
export class DomainModule {}

View File

@ -1 +1 @@
export * from './wallet-address.entity'; export * from './wallet-address.entity';

View File

@ -1,286 +1,327 @@
import { HDKey } from '@scure/bip32'; import { HDKey } from '@scure/bip32';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { bech32 } from 'bech32'; import { bech32 } from 'bech32';
import { Wallet } from 'ethers'; import { Wallet } from 'ethers';
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
import { import {
AddressId, AddressId,
UserId, UserId,
ChainType, ChainType,
CHAIN_CONFIG, CHAIN_CONFIG,
AddressStatus, AddressStatus,
Mnemonic, Mnemonic,
MnemonicEncryption, MnemonicEncryption,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
/** /**
* MPC * MPC
* 64 bytes hex (R 32 bytes + S 32 bytes) * 64 bytes hex (R 32 bytes + S 32 bytes)
*/ */
export type MpcSignature = string; export type MpcSignature = string;
export class WalletAddress { export class WalletAddress {
private readonly _addressId: AddressId; private readonly _addressId: AddressId;
private readonly _userId: UserId; private readonly _userId: UserId;
private readonly _chainType: ChainType; private readonly _chainType: ChainType;
private readonly _address: string; private readonly _address: string;
private readonly _publicKey: string; // MPC 公钥 private readonly _publicKey: string; // MPC 公钥
private readonly _addressDigest: string; // 地址摘要 private readonly _addressDigest: string; // 地址摘要
private readonly _mpcSignature: MpcSignature; // MPC 签名 private readonly _mpcSignature: MpcSignature; // MPC 签名
private _status: AddressStatus; private _status: AddressStatus;
private readonly _boundAt: Date; private readonly _boundAt: Date;
get addressId(): AddressId { return this._addressId; } get addressId(): AddressId {
get userId(): UserId { return this._userId; } return this._addressId;
get chainType(): ChainType { return this._chainType; } }
get address(): string { return this._address; } get userId(): UserId {
get publicKey(): string { return this._publicKey; } return this._userId;
get addressDigest(): string { return this._addressDigest; } }
get mpcSignature(): MpcSignature { return this._mpcSignature; } get chainType(): ChainType {
get status(): AddressStatus { return this._status; } return this._chainType;
get boundAt(): Date { return this._boundAt; } }
get address(): string {
private constructor( return this._address;
addressId: AddressId, }
userId: UserId, get publicKey(): string {
chainType: ChainType, return this._publicKey;
address: string, }
publicKey: string, get addressDigest(): string {
addressDigest: string, return this._addressDigest;
mpcSignature: MpcSignature, }
status: AddressStatus, get mpcSignature(): MpcSignature {
boundAt: Date, return this._mpcSignature;
) { }
this._addressId = addressId; get status(): AddressStatus {
this._userId = userId; return this._status;
this._chainType = chainType; }
this._address = address; get boundAt(): Date {
this._publicKey = publicKey; return this._boundAt;
this._addressDigest = addressDigest; }
this._mpcSignature = mpcSignature;
this._status = status; private constructor(
this._boundAt = boundAt; addressId: AddressId,
} userId: UserId,
chainType: ChainType,
/** address: string,
* MPC publicKey: string,
* addressDigest: string,
* @param params MPC mpcSignature: MpcSignature,
*/ status: AddressStatus,
static createMpc(params: { boundAt: Date,
userId: UserId; ) {
chainType: ChainType; this._addressId = addressId;
address: string; this._userId = userId;
publicKey: string; this._chainType = chainType;
addressDigest: string; this._address = address;
signature: MpcSignature; this._publicKey = publicKey;
}): WalletAddress { this._addressDigest = addressDigest;
if (!this.validateEvmAddress(params.address)) { this._mpcSignature = mpcSignature;
throw new DomainError(`${params.chainType}地址格式错误`); this._status = status;
} this._boundAt = boundAt;
return new WalletAddress( }
AddressId.generate(),
params.userId, /**
params.chainType, * MPC
params.address, *
params.publicKey, * @param params MPC
params.addressDigest, */
params.signature, static createMpc(params: {
AddressStatus.ACTIVE, userId: UserId;
new Date(), chainType: ChainType;
); address: string;
} publicKey: string;
addressDigest: string;
/** signature: MpcSignature;
* }): WalletAddress {
*/ if (!this.validateEvmAddress(params.address)) {
static reconstruct(params: { throw new DomainError(`${params.chainType}地址格式错误`);
addressId: string; }
userId: string; return new WalletAddress(
chainType: ChainType; AddressId.generate(),
address: string; params.userId,
publicKey: string; params.chainType,
addressDigest: string; params.address,
mpcSignature: string; // 64 bytes hex params.publicKey,
status: AddressStatus; params.addressDigest,
boundAt: Date; params.signature,
}): WalletAddress { AddressStatus.ACTIVE,
return new WalletAddress( new Date(),
AddressId.create(params.addressId), );
UserId.create(params.userId), }
params.chainType,
params.address, /**
params.publicKey, *
params.addressDigest, */
params.mpcSignature, static reconstruct(params: {
params.status, addressId: string;
params.boundAt, userId: string;
); chainType: ChainType;
} address: string;
publicKey: string;
disable(): void { addressDigest: string;
this._status = AddressStatus.DISABLED; mpcSignature: string; // 64 bytes hex
} status: AddressStatus;
boundAt: Date;
enable(): void { }): WalletAddress {
this._status = AddressStatus.ACTIVE; return new WalletAddress(
} AddressId.create(params.addressId),
UserId.create(params.userId),
/** params.chainType,
* params.address,
* params.publicKey,
*/ params.addressDigest,
async verifySignature(): Promise<boolean> { params.mpcSignature,
try { params.status,
const { ethers } = await import('ethers'); params.boundAt,
);
// 计算预期的摘要 }
const expectedDigest = this.computeDigest();
if (expectedDigest !== this._addressDigest) { disable(): void {
return false; this._status = AddressStatus.DISABLED;
} }
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars enable(): void {
if (this._mpcSignature.length !== 128) { this._status = AddressStatus.ACTIVE;
return false; }
}
/**
const r = '0x' + this._mpcSignature.slice(0, 64); *
const s = '0x' + this._mpcSignature.slice(64, 128); *
const digestBytes = Buffer.from(this._addressDigest, 'hex'); */
async verifySignature(): Promise<boolean> {
// 尝试两种 recovery id try {
for (const v of [27, 28]) { const { ethers } = await import('ethers');
try {
const sig = ethers.Signature.from({ r, s, v }); // 计算预期的摘要
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); const expectedDigest = this.computeDigest();
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true); if (expectedDigest !== this._addressDigest) {
return false;
if (compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase()) { }
return true;
} // 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars
} catch { if (this._mpcSignature.length !== 128) {
// 尝试下一个 v 值 return false;
} }
}
const r = '0x' + this._mpcSignature.slice(0, 64);
return false; const s = '0x' + this._mpcSignature.slice(64, 128);
} catch { const digestBytes = Buffer.from(this._addressDigest, 'hex');
return false;
} // 尝试两种 recovery id
} for (const v of [27, 28]) {
try {
/** const sig = ethers.Signature.from({ r, s, v });
* const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
*/ digestBytes,
private computeDigest(): string { sig,
const message = `${this._chainType}:${this._address.toLowerCase()}`; );
return createHash('sha256').update(message).digest('hex'); const compressedRecovered = ethers.SigningKey.computePublicKey(
} recoveredPubKey,
true,
/** );
* EVM
*/ if (
private static validateEvmAddress(address: string): boolean { compressedRecovered.slice(2).toLowerCase() ===
return /^0x[a-fA-F0-9]{40}$/.test(address); this._publicKey.toLowerCase()
} ) {
return true;
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ==================== }
} catch {
/** // 尝试下一个 v 值
* blockchain-service }
*/ }
static create(params: {
userId: UserId; return false;
chainType: ChainType; } catch {
address: string; return false;
publicKey?: string; // 公钥 }
}): WalletAddress { }
if (!this.validateAddress(params.chainType, params.address)) {
throw new DomainError(`${params.chainType}地址格式错误`); /**
} *
return new WalletAddress( */
AddressId.generate(), private computeDigest(): string {
params.userId, const message = `${this._chainType}:${this._address.toLowerCase()}`;
params.chainType, return createHash('sha256').update(message).digest('hex');
params.address, }
params.publicKey || '',
'', /**
'', // empty signature * EVM
AddressStatus.ACTIVE, */
new Date(), private static validateEvmAddress(address: string): boolean {
); return /^0x[a-fA-F0-9]{40}$/.test(address);
} }
/** // ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
* @deprecated MPC 使
*/ /**
static createFromMnemonic(params: { * blockchain-service
userId: UserId; */
chainType: ChainType; static create(params: {
mnemonic: Mnemonic; userId: UserId;
encryptionKey: string; chainType: ChainType;
}): WalletAddress { address: string;
const address = this.deriveAddress(params.chainType, params.mnemonic); publicKey?: string; // 公钥
return new WalletAddress( }): WalletAddress {
AddressId.generate(), if (!this.validateAddress(params.chainType, params.address)) {
params.userId, throw new DomainError(`${params.chainType}地址格式错误`);
params.chainType, }
address, return new WalletAddress(
'', AddressId.generate(),
'', params.userId,
'', // empty signature params.chainType,
AddressStatus.ACTIVE, params.address,
new Date(), params.publicKey || '',
); '',
} '', // empty signature
AddressStatus.ACTIVE,
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string { new Date(),
const seed = mnemonic.toSeed(); );
const config = CHAIN_CONFIG[chainType]; }
switch (chainType) { /**
case ChainType.KAVA: * @deprecated MPC 使
case ChainType.DST: */
return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix); static createFromMnemonic(params: {
case ChainType.BSC: userId: UserId;
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath); chainType: ChainType;
default: mnemonic: Mnemonic;
throw new DomainError(`不支持的链类型: ${chainType}`); encryptionKey: string;
} }): WalletAddress {
} const address = this.deriveAddress(params.chainType, params.mnemonic);
return new WalletAddress(
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string { AddressId.generate(),
const hdkey = HDKey.fromMasterSeed(seed); params.userId,
const childKey = hdkey.derive(path); params.chainType,
if (!childKey.publicKey) throw new DomainError('无法派生公钥'); address,
'',
const hash = createHash('sha256').update(childKey.publicKey).digest(); '',
const addressHash = createHash('ripemd160').update(hash).digest(); '', // empty signature
const words = bech32.toWords(addressHash); AddressStatus.ACTIVE,
return bech32.encode(prefix, words); new Date(),
} );
}
private static deriveEVMAddress(seed: Buffer, path: string): string {
const hdkey = HDKey.fromMasterSeed(seed); private static deriveAddress(
const childKey = hdkey.derive(path); chainType: ChainType,
if (!childKey.privateKey) throw new DomainError('无法派生私钥'); mnemonic: Mnemonic,
): string {
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex')); const seed = mnemonic.toSeed();
return wallet.address; const config = CHAIN_CONFIG[chainType];
}
switch (chainType) {
private static validateAddress(chainType: ChainType, address: string): boolean { case ChainType.KAVA:
switch (chainType) { case ChainType.DST:
case ChainType.KAVA: return this.deriveCosmosAddress(
case ChainType.BSC: Buffer.from(seed),
// KAVA 和 BSC 都使用 EVM 地址格式 config.derivationPath,
return /^0x[a-fA-F0-9]{40}$/.test(address); config.prefix,
case ChainType.DST: );
// DST 使用 Cosmos bech32 格式 case ChainType.BSC:
return /^dst1[a-z0-9]{38}$/.test(address); return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
default: default:
return false; throw new DomainError(`不支持的链类型: ${chainType}`);
} }
} }
}
private static deriveCosmosAddress(
seed: Buffer,
path: string,
prefix: string,
): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
const hash = createHash('sha256').update(childKey.publicKey).digest();
const addressHash = createHash('ripemd160').update(hash).digest();
const words = bech32.toWords(addressHash);
return bech32.encode(prefix, words);
}
private static deriveEVMAddress(seed: Buffer, path: string): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
return wallet.address;
}
private static validateAddress(
chainType: ChainType,
address: string,
): boolean {
switch (chainType) {
case ChainType.KAVA:
case ChainType.BSC:
// KAVA 和 BSC 都使用 EVM 地址格式
return /^0x[a-fA-F0-9]{40}$/.test(address);
case ChainType.DST:
// DST 使用 Cosmos bech32 格式
return /^dst1[a-z0-9]{38}$/.test(address);
default:
return false;
}
}
}

View File

@ -1,5 +1,5 @@
export enum AccountStatus { export enum AccountStatus {
ACTIVE = 'ACTIVE', ACTIVE = 'ACTIVE',
FROZEN = 'FROZEN', FROZEN = 'FROZEN',
DEACTIVATED = 'DEACTIVATED', DEACTIVATED = 'DEACTIVATED',
} }

View File

@ -1,20 +1,20 @@
export enum ChainType { export enum ChainType {
KAVA = 'KAVA', KAVA = 'KAVA',
DST = 'DST', DST = 'DST',
BSC = 'BSC', BSC = 'BSC',
} }
export const CHAIN_CONFIG = { export const CHAIN_CONFIG = {
[ChainType.KAVA]: { [ChainType.KAVA]: {
prefix: 'kava', prefix: 'kava',
derivationPath: "m/44'/459'/0'/0/0", derivationPath: "m/44'/459'/0'/0/0",
}, },
[ChainType.DST]: { [ChainType.DST]: {
prefix: 'dst', prefix: 'dst',
derivationPath: "m/44'/118'/0'/0/0", derivationPath: "m/44'/118'/0'/0/0",
}, },
[ChainType.BSC]: { [ChainType.BSC]: {
prefix: '0x', prefix: '0x',
derivationPath: "m/44'/60'/0'/0/0", derivationPath: "m/44'/60'/0'/0/0",
}, },
}; };

View File

@ -1,3 +1,3 @@
export * from './chain-type.enum'; export * from './chain-type.enum';
export * from './kyc-status.enum'; export * from './kyc-status.enum';
export * from './account-status.enum'; export * from './account-status.enum';

View File

@ -1,6 +1,6 @@
export enum KYCStatus { export enum KYCStatus {
NOT_VERIFIED = 'NOT_VERIFIED', NOT_VERIFIED = 'NOT_VERIFIED',
PENDING = 'PENDING', PENDING = 'PENDING',
VERIFIED = 'VERIFIED', VERIFIED = 'VERIFIED',
REJECTED = 'REJECTED', REJECTED = 'REJECTED',
} }

View File

@ -1,18 +1,18 @@
import { DomainEvent } from './index'; import { DomainEvent } from './index';
export class DeviceAddedEvent extends DomainEvent { export class DeviceAddedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: {
userId: string; userId: string;
accountSequence: number; accountSequence: number;
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
}, },
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'DeviceAdded'; return 'DeviceAdded';
} }
} }

View File

@ -1,15 +1,15 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export abstract class DomainEvent { export abstract class DomainEvent {
public readonly eventId: string; public readonly eventId: string;
public readonly occurredAt: Date; public readonly occurredAt: Date;
constructor() { constructor() {
this.eventId = uuidv4(); this.eventId = uuidv4();
this.occurredAt = new Date(); this.occurredAt = new Date();
} }
abstract get eventType(): string; abstract get eventType(): string;
abstract get aggregateId(): string; abstract get aggregateId(): string;
abstract get aggregateType(): string; abstract get aggregateType(): string;
} }

View File

@ -1,328 +1,344 @@
export abstract class DomainEvent { export abstract class DomainEvent {
public readonly occurredAt: Date; public readonly occurredAt: Date;
public readonly eventId: string; public readonly eventId: string;
constructor() { constructor() {
this.occurredAt = new Date(); this.occurredAt = new Date();
this.eventId = crypto.randomUUID(); this.eventId = crypto.randomUUID();
} }
abstract get eventType(): string; abstract get eventType(): string;
} }
export class UserAccountAutoCreatedEvent extends DomainEvent { export class UserAccountAutoCreatedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成) referralCode: string; // 用户的推荐码(由 identity-service 生成)
initialDeviceId: string; initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号 inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: Date; registeredAt: Date;
}, },
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'UserAccountAutoCreated'; return 'UserAccountAutoCreated';
} }
} }
export class UserAccountCreatedEvent extends DomainEvent { export class UserAccountCreatedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成) referralCode: string; // 用户的推荐码(由 identity-service 生成)
phoneNumber: string; phoneNumber: string;
initialDeviceId: string; initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号 inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: Date; registeredAt: Date;
}, },
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'UserAccountCreated'; return 'UserAccountCreated';
} }
} }
export class DeviceAddedEvent extends DomainEvent { export class DeviceAddedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
}, },
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'DeviceAdded'; return 'DeviceAdded';
} }
} }
export class DeviceRemovedEvent extends DomainEvent { export class DeviceRemovedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; deviceId: string }) { constructor(public readonly payload: { userId: string; deviceId: string }) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'DeviceRemoved'; return 'DeviceRemoved';
} }
} }
export class PhoneNumberBoundEvent extends DomainEvent { export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) { constructor(
super(); public readonly payload: { userId: string; phoneNumber: string },
} ) {
super();
get eventType(): string { }
return 'PhoneNumberBound';
} get eventType(): string {
} return 'PhoneNumberBound';
}
export class WalletAddressBoundEvent extends DomainEvent { }
constructor(public readonly payload: { userId: string; chainType: string; address: string }) {
super(); export class WalletAddressBoundEvent extends DomainEvent {
} constructor(
public readonly payload: {
get eventType(): string { userId: string;
return 'WalletAddressBound'; chainType: string;
} address: string;
} },
) {
export class MultipleWalletAddressesBoundEvent extends DomainEvent { super();
constructor( }
public readonly payload: {
userId: string; get eventType(): string {
addresses: Array<{ chainType: string; address: string }>; return 'WalletAddressBound';
}, }
) { }
super();
} export class MultipleWalletAddressesBoundEvent extends DomainEvent {
constructor(
get eventType(): string { public readonly payload: {
return 'MultipleWalletAddressesBound'; userId: string;
} addresses: Array<{ chainType: string; address: string }>;
} },
) {
export class KYCSubmittedEvent extends DomainEvent { super();
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) { }
super();
} get eventType(): string {
return 'MultipleWalletAddressesBound';
get eventType(): string { }
return 'KYCSubmitted'; }
}
} export class KYCSubmittedEvent extends DomainEvent {
constructor(
export class KYCVerifiedEvent extends DomainEvent { public readonly payload: {
constructor(public readonly payload: { userId: string; verifiedAt: Date }) { userId: string;
super(); realName: string;
} idCardNumber: string;
},
get eventType(): string { ) {
return 'KYCVerified'; super();
} }
}
get eventType(): string {
export class KYCRejectedEvent extends DomainEvent { return 'KYCSubmitted';
constructor(public readonly payload: { userId: string; reason: string }) { }
super(); }
}
export class KYCVerifiedEvent extends DomainEvent {
get eventType(): string { constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
return 'KYCRejected'; super();
} }
}
get eventType(): string {
export class UserAccountFrozenEvent extends DomainEvent { return 'KYCVerified';
constructor(public readonly payload: { userId: string; reason: string }) { }
super(); }
}
export class KYCRejectedEvent extends DomainEvent {
get eventType(): string { constructor(public readonly payload: { userId: string; reason: string }) {
return 'UserAccountFrozen'; super();
} }
}
get eventType(): string {
export class UserAccountDeactivatedEvent extends DomainEvent { return 'KYCRejected';
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) { }
super(); }
}
export class UserAccountFrozenEvent extends DomainEvent {
get eventType(): string { constructor(public readonly payload: { userId: string; reason: string }) {
return 'UserAccountDeactivated'; super();
} }
}
get eventType(): string {
/** return 'UserAccountFrozen';
* }
* }
*/
export class UserProfileUpdatedEvent extends DomainEvent { export class UserAccountDeactivatedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: { userId: string; deactivatedAt: Date },
userId: string; ) {
accountSequence: string; super();
nickname: string | null; }
avatarUrl: string | null;
updatedAt: Date; get eventType(): string {
}, return 'UserAccountDeactivated';
) { }
super(); }
}
/**
get eventType(): string { *
return 'UserProfileUpdated'; *
} */
} export class UserProfileUpdatedEvent extends DomainEvent {
constructor(
/** public readonly payload: {
* MPC userId: string;
* MPC accountSequence: string;
* nickname: string | null;
* payload mpc-service KeygenRequestedPayload : avatarUrl: string | null;
* - sessionId: 唯一会话ID updatedAt: Date;
* - userId: 用户ID },
* - accountSequence: 8位账户序列号 () ) {
* - username: 用户名 ( mpc-system ) super();
* - threshold: 签名阈值 ( 2) }
* - totalParties: 总参与方数 ( 3)
* - requireDelegate: 是否需要委托分片 ( true) get eventType(): string {
*/ return 'UserProfileUpdated';
export class MpcKeygenRequestedEvent extends DomainEvent { }
constructor( }
public readonly payload: {
sessionId: string; /**
userId: string; * MPC
accountSequence: string; // 格式: D + YYMMDD + 5位序号 * MPC
username: string; *
threshold: number; * payload mpc-service KeygenRequestedPayload :
totalParties: number; * - sessionId: 唯一会话ID
requireDelegate: boolean; * - userId: 用户ID
}, * - accountSequence: 8位账户序列号 ()
) { * - username: 用户名 ( mpc-system )
super(); * - threshold: 签名阈值 ( 2)
} * - totalParties: 总参与方数 ( 3)
* - requireDelegate: 是否需要委托分片 ( true)
get eventType(): string { */
return 'MpcKeygenRequested'; export class MpcKeygenRequestedEvent extends DomainEvent {
} constructor(
} public readonly payload: {
sessionId: string;
// ============ 账户恢复相关事件 ============ userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
/** username: string;
* () threshold: number;
*/ totalParties: number;
export class AccountRecoveredEvent extends DomainEvent { requireDelegate: boolean;
constructor( },
public readonly payload: { ) {
userId: string; super();
accountSequence: string; }
recoveryMethod: 'mnemonic' | 'phone';
deviceId: string; get eventType(): string {
deviceName?: string; return 'MpcKeygenRequested';
ipAddress?: string; }
userAgent?: string; }
recoveredAt: Date;
}, // ============ 账户恢复相关事件 ============
) {
super(); /**
} * ()
*/
get eventType(): string { export class AccountRecoveredEvent extends DomainEvent {
return 'AccountRecovered'; constructor(
} public readonly payload: {
} userId: string;
accountSequence: string;
/** recoveryMethod: 'mnemonic' | 'phone';
* () deviceId: string;
*/ deviceName?: string;
export class AccountRecoveryFailedEvent extends DomainEvent { ipAddress?: string;
constructor( userAgent?: string;
public readonly payload: { recoveredAt: Date;
accountSequence: string; },
recoveryMethod: 'mnemonic' | 'phone'; ) {
failureReason: string; super();
deviceId?: string; }
ipAddress?: string;
userAgent?: string; get eventType(): string {
attemptedAt: Date; return 'AccountRecovered';
}, }
) { }
super();
} /**
* ()
get eventType(): string { */
return 'AccountRecoveryFailed'; export class AccountRecoveryFailedEvent extends DomainEvent {
} constructor(
} public readonly payload: {
accountSequence: string;
/** recoveryMethod: 'mnemonic' | 'phone';
* () failureReason: string;
*/ deviceId?: string;
export class MnemonicRevokedEvent extends DomainEvent { ipAddress?: string;
constructor( userAgent?: string;
public readonly payload: { attemptedAt: Date;
userId: string; },
accountSequence: string; ) {
reason: string; super();
revokedAt: Date; }
},
) { get eventType(): string {
super(); return 'AccountRecoveryFailed';
} }
}
get eventType(): string {
return 'MnemonicRevoked'; /**
} * ()
} */
export class MnemonicRevokedEvent extends DomainEvent {
/** constructor(
* () public readonly payload: {
*/ userId: string;
export class AccountUnfrozenEvent extends DomainEvent { accountSequence: string;
constructor( reason: string;
public readonly payload: { revokedAt: Date;
userId: string; },
accountSequence: string; ) {
verifyMethod: 'mnemonic' | 'phone'; super();
unfrozenAt: Date; }
},
) { get eventType(): string {
super(); return 'MnemonicRevoked';
} }
}
get eventType(): string {
return 'AccountUnfrozen'; /**
} * ()
} */
export class AccountUnfrozenEvent extends DomainEvent {
/** constructor(
* public readonly payload: {
* MPC userId: string;
*/ accountSequence: string;
export class KeyRotationRequestedEvent extends DomainEvent { verifyMethod: 'mnemonic' | 'phone';
constructor( unfrozenAt: Date;
public readonly payload: { },
sessionId: string; ) {
userId: string; super();
accountSequence: string; }
reason: string;
requestedAt: Date; get eventType(): string {
}, return 'AccountUnfrozen';
) { }
super(); }
}
/**
get eventType(): string { *
return 'KeyRotationRequested'; * MPC
} */
} export class KeyRotationRequestedEvent extends DomainEvent {
constructor(
public readonly payload: {
sessionId: string;
userId: string;
accountSequence: string;
reason: string;
requestedAt: Date;
},
) {
super();
}
get eventType(): string {
return 'KeyRotationRequested';
}
}

View File

@ -1,31 +1,31 @@
import { DomainEvent } from './domain-event.base'; import { DomainEvent } from './domain-event.base';
export class KYCSubmittedEvent extends DomainEvent { export class KYCSubmittedEvent extends DomainEvent {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly realName: string, public readonly realName: string,
public readonly idCardNumber: string, public readonly idCardNumber: string,
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'KYCSubmitted'; return 'KYCSubmitted';
} }
get aggregateId(): string { get aggregateId(): string {
return this.userId; return this.userId;
} }
get aggregateType(): string { get aggregateType(): string {
return 'UserAccount'; return 'UserAccount';
} }
toPayload(): object { toPayload(): object {
return { return {
userId: this.userId, userId: this.userId,
realName: this.realName, realName: this.realName,
idCardNumber: this.idCardNumber, idCardNumber: this.idCardNumber,
}; };
} }
} }

View File

@ -1,11 +1,13 @@
import { DomainEvent } from './index'; import { DomainEvent } from './index';
export class PhoneNumberBoundEvent extends DomainEvent { export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) { constructor(
super(); public readonly payload: { userId: string; phoneNumber: string },
} ) {
super();
get eventType(): string { }
return 'PhoneNumberBound';
} get eventType(): string {
} return 'PhoneNumberBound';
}
}

View File

@ -1,29 +1,29 @@
import { DomainEvent } from './domain-event.base'; import { DomainEvent } from './domain-event.base';
export class PhoneNumberBoundEvent extends DomainEvent { export class PhoneNumberBoundEvent extends DomainEvent {
constructor( constructor(
public readonly userId: string, public readonly userId: string,
public readonly phoneNumber: string, public readonly phoneNumber: string,
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'PhoneNumberBound'; return 'PhoneNumberBound';
} }
get aggregateId(): string { get aggregateId(): string {
return this.userId; return this.userId;
} }
get aggregateType(): string { get aggregateType(): string {
return 'UserAccount'; return 'UserAccount';
} }
toPayload(): object { toPayload(): object {
return { return {
userId: this.userId, userId: this.userId,
phoneNumber: this.phoneNumber, phoneNumber: this.phoneNumber,
}; };
} }
} }

View File

@ -1,22 +1,22 @@
import { DomainEvent } from './index'; import { DomainEvent } from './index';
export class UserAccountCreatedEvent extends DomainEvent { export class UserAccountCreatedEvent extends DomainEvent {
constructor( constructor(
public readonly payload: { public readonly payload: {
userId: string; userId: string;
accountSequence: number; accountSequence: number;
phoneNumber: string; phoneNumber: string;
initialDeviceId: string; initialDeviceId: string;
inviterSequence: number | null; inviterSequence: number | null;
province: string; province: string;
city: string; city: string;
registeredAt: Date; registeredAt: Date;
}, },
) { ) {
super(); super();
} }
get eventType(): string { get eventType(): string {
return 'UserAccountCreated'; return 'UserAccountCreated';
} }
} }

View File

@ -1 +1 @@
export * from './user-account.repository.interface'; export * from './user-account.repository.interface';

View File

@ -1,52 +1,52 @@
import { UserId } from '@/domain/value-objects'; import { UserId } from '@/domain/value-objects';
export interface MpcKeyShareData { export interface MpcKeyShareData {
userId: bigint; userId: bigint;
publicKey: string; publicKey: string;
partyIndex: number; partyIndex: number;
threshold: number; threshold: number;
totalParties: number; totalParties: number;
encryptedShareData: string; encryptedShareData: string;
} }
export interface MpcKeyShare { export interface MpcKeyShare {
shareId: bigint; shareId: bigint;
userId: bigint; userId: bigint;
publicKey: string; publicKey: string;
partyIndex: number; partyIndex: number;
threshold: number; threshold: number;
totalParties: number; totalParties: number;
encryptedShareData: string; encryptedShareData: string;
status: string; status: string;
createdAt: Date; createdAt: Date;
rotatedAt: Date | null; rotatedAt: Date | null;
} }
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY'); export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
export interface MpcKeyShareRepository { export interface MpcKeyShareRepository {
/** /**
* MPC * MPC
*/ */
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>; saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
/** /**
* ID查找分片 * ID查找分片
*/ */
findByUserId(userId: UserId): Promise<MpcKeyShare | null>; findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
/** /**
* *
*/ */
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>; findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
/** /**
* () * ()
*/ */
updateStatus(shareId: bigint, status: string): Promise<void>; updateStatus(shareId: bigint, status: string): Promise<void>;
/** /**
* () * ()
*/ */
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>; rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
} }

View File

@ -1,53 +1,73 @@
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus, UserId,
} from '@/domain/value-objects'; AccountSequence,
PhoneNumber,
export interface Pagination { ReferralCode,
page: number; ChainType,
limit: number; AccountStatus,
} KYCStatus,
} from '@/domain/value-objects';
export interface ReferralLinkData {
linkId: bigint; export interface Pagination {
userId: bigint; page: number;
referralCode: string; limit: number;
shortCode: string; }
channel: string | null;
campaignId: string | null; export interface ReferralLinkData {
createdAt: Date; linkId: bigint;
} userId: bigint;
referralCode: string;
export interface CreateReferralLinkParams { shortCode: string;
userId: bigint; channel: string | null;
referralCode: string; campaignId: string | null;
shortCode: string; createdAt: Date;
channel: string | null; }
campaignId: string | null;
} export interface CreateReferralLinkParams {
userId: bigint;
export interface UserAccountRepository { referralCode: string;
save(account: UserAccount): Promise<void>; shortCode: string;
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>; channel: string | null;
findById(userId: UserId): Promise<UserAccount | null>; campaignId: string | null;
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>; }
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>; export interface UserAccountRepository {
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>; save(account: UserAccount): Promise<void>;
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>; saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
getMaxAccountSequence(): Promise<AccountSequence | null>; findById(userId: UserId): Promise<UserAccount | null>;
getNextAccountSequence(): Promise<AccountSequence>; findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
findUsers( findByDeviceId(deviceId: string): Promise<UserAccount | null>;
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string }, findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
pagination?: Pagination, findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
): Promise<UserAccount[]>; findByWalletAddress(
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>; chainType: ChainType,
address: string,
// 推荐相关 ): Promise<UserAccount | null>;
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>; getMaxAccountSequence(): Promise<AccountSequence | null>;
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>; getNextAccountSequence(): Promise<AccountSequence>;
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>; findUsers(
} filters?: {
status?: AccountStatus;
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY'); kycStatus?: KYCStatus;
keyword?: string;
},
pagination?: Pagination,
): Promise<UserAccount[]>;
countUsers(filters?: {
status?: AccountStatus;
kycStatus?: KYCStatus;
}): Promise<number>;
// 推荐相关
findByInviterSequence(
inviterSequence: AccountSequence,
): Promise<UserAccount[]>;
createReferralLink(
params: CreateReferralLinkParams,
): Promise<ReferralLinkData>;
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
}
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');

View File

@ -1,15 +1,18 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { AccountSequence } from '@/domain/value-objects'; UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
@Injectable() } from '@/domain/repositories/user-account.repository.interface';
export class AccountSequenceGeneratorService { import { AccountSequence } from '@/domain/value-objects';
constructor(
@Inject(USER_ACCOUNT_REPOSITORY) @Injectable()
private readonly repository: UserAccountRepository, export class AccountSequenceGeneratorService {
) {} constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
async generateNextUserSequence(): Promise<AccountSequence> { private readonly repository: UserAccountRepository,
return this.repository.getNextAccountSequence(); ) {}
}
} async generateNextUserSequence(): Promise<AccountSequence> {
return this.repository.getNextAccountSequence();
}
}

View File

@ -1,68 +1,87 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects'; UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
// ============ ValidationResult ============ } from '@/domain/repositories/user-account.repository.interface';
export class ValidationResult { import {
private constructor( AccountSequence,
public readonly isValid: boolean, PhoneNumber,
public readonly errorMessage: string | null, ReferralCode,
) {} ChainType,
} from '@/domain/value-objects';
static success(): ValidationResult {
return new ValidationResult(true, null); // ============ ValidationResult ============
} export class ValidationResult {
private constructor(
static failure(message: string): ValidationResult { public readonly isValid: boolean,
return new ValidationResult(false, message); public readonly errorMessage: string | null,
} ) {}
}
static success(): ValidationResult {
// ============ AccountSequenceGeneratorService ============ return new ValidationResult(true, null);
@Injectable() }
export class AccountSequenceGeneratorService {
constructor( static failure(message: string): ValidationResult {
@Inject(USER_ACCOUNT_REPOSITORY) return new ValidationResult(false, message);
private readonly repository: UserAccountRepository, }
) {} }
async generateNextUserSequence(): Promise<AccountSequence> { // ============ AccountSequenceGeneratorService ============
return this.repository.getNextAccountSequence(); @Injectable()
} export class AccountSequenceGeneratorService {
} constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
// ============ UserValidatorService ============ private readonly repository: UserAccountRepository,
@Injectable() ) {}
export class UserValidatorService {
constructor( async generateNextUserSequence(): Promise<AccountSequence> {
@Inject(USER_ACCOUNT_REPOSITORY) return this.repository.getNextAccountSequence();
private readonly repository: UserAccountRepository, }
) {} }
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> { // ============ UserValidatorService ============
const existing = await this.repository.findByPhoneNumber(phoneNumber); @Injectable()
if (existing) return ValidationResult.failure('该手机号已注册'); export class UserValidatorService {
return ValidationResult.success(); constructor(
} @Inject(USER_ACCOUNT_REPOSITORY)
private readonly repository: UserAccountRepository,
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> { ) {}
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
return ValidationResult.success(); async validatePhoneNumber(
// const existing = await this.repository.findByDeviceId(deviceId); phoneNumber: PhoneNumber,
// if (existing) return ValidationResult.failure('该设备已创建过账户'); ): Promise<ValidationResult> {
// return ValidationResult.success(); const existing = await this.repository.findByPhoneNumber(phoneNumber);
} if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success();
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> { }
const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在'); async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); // TODO: 暂时禁用设备检查,允许同一设备创建多个账户
return ValidationResult.success(); return ValidationResult.success();
} // const existing = await this.repository.findByDeviceId(deviceId);
// if (existing) return ValidationResult.failure('该设备已创建过账户');
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> { // return ValidationResult.success();
const existing = await this.repository.findByWalletAddress(chainType, address); }
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success(); async validateReferralCode(
} referralCode: ReferralCode,
} ): Promise<ValidationResult> {
const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在');
if (!inviter.isActive)
return ValidationResult.failure('推荐人账户已冻结或注销');
return ValidationResult.success();
}
async validateWalletAddress(
chainType: ChainType,
address: string,
): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(
chainType,
address,
);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success();
}
}

View File

@ -1,53 +1,67 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import {
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects'; UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
export class ValidationResult { } from '@/domain/repositories/user-account.repository.interface';
private constructor( import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
public readonly isValid: boolean,
public readonly errorMessage: string | null, export class ValidationResult {
) {} private constructor(
public readonly isValid: boolean,
static success(): ValidationResult { public readonly errorMessage: string | null,
return new ValidationResult(true, null); ) {}
}
static success(): ValidationResult {
static failure(message: string): ValidationResult { return new ValidationResult(true, null);
return new ValidationResult(false, message); }
}
} static failure(message: string): ValidationResult {
return new ValidationResult(false, message);
@Injectable() }
export class UserValidatorService { }
constructor(
@Inject(USER_ACCOUNT_REPOSITORY) @Injectable()
private readonly repository: UserAccountRepository, export class UserValidatorService {
) {} constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> { private readonly repository: UserAccountRepository,
const existing = await this.repository.findByPhoneNumber(phoneNumber); ) {}
if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success(); async validatePhoneNumber(
} phoneNumber: PhoneNumber,
): Promise<ValidationResult> {
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> { const existing = await this.repository.findByPhoneNumber(phoneNumber);
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户 if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success(); return ValidationResult.success();
// const existing = await this.repository.findByDeviceId(deviceId); }
// if (existing) return ValidationResult.failure('该设备已创建过账户');
// return ValidationResult.success(); async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
} // TODO: 暂时禁用设备检查,允许同一设备创建多个账户
return ValidationResult.success();
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> { // const existing = await this.repository.findByDeviceId(deviceId);
const inviter = await this.repository.findByReferralCode(referralCode); // if (existing) return ValidationResult.failure('该设备已创建过账户');
if (!inviter) return ValidationResult.failure('推荐码不存在'); // return ValidationResult.success();
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); }
return ValidationResult.success();
} async validateReferralCode(
referralCode: ReferralCode,
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> { ): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(chainType, address); const inviter = await this.repository.findByReferralCode(referralCode);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定'); if (!inviter) return ValidationResult.failure('推荐码不存在');
return ValidationResult.success(); if (!inviter.isActive)
} return ValidationResult.failure('推荐人账户已冻结或注销');
} return ValidationResult.success();
}
async validateWalletAddress(
chainType: ChainType,
address: string,
): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(
chainType,
address,
);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success();
}
}

View File

@ -1,58 +1,60 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
/** /**
* *
* 格式: D + (2) + (2) + (2) + 5 * 格式: D + (2) + (2) + (2) + 5
* 示例: D2512110008 -> 202512118 * 示例: D2512110008 -> 202512118
*/ */
export class AccountSequence { export class AccountSequence {
private static readonly PATTERN = /^D\d{11}$/; private static readonly PATTERN = /^D\d{11}$/;
constructor(public readonly value: string) { constructor(public readonly value: string) {
if (!AccountSequence.PATTERN.test(value)) { if (!AccountSequence.PATTERN.test(value)) {
throw new DomainError(`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`); throw new DomainError(
} `账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`,
} );
}
static create(value: string): AccountSequence { }
return new AccountSequence(value);
} static create(value: string): AccountSequence {
return new AccountSequence(value);
/** }
*
* @param date /**
* @param dailySequence (0-99999) *
*/ * @param date
static generate(date: Date, dailySequence: number): AccountSequence { * @param dailySequence (0-99999)
if (dailySequence < 0 || dailySequence > 99999) { */
throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`); static generate(date: Date, dailySequence: number): AccountSequence {
} if (dailySequence < 0 || dailySequence > 99999) {
const year = String(date.getFullYear()).slice(-2); throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`);
const month = String(date.getMonth() + 1).padStart(2, '0'); }
const day = String(date.getDate()).padStart(2, '0'); const year = String(date.getFullYear()).slice(-2);
const seq = String(dailySequence).padStart(5, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
return new AccountSequence(`D${year}${month}${day}${seq}`); const day = String(date.getDate()).padStart(2, '0');
} const seq = String(dailySequence).padStart(5, '0');
return new AccountSequence(`D${year}${month}${day}${seq}`);
/** }
* (YYMMDD)
*/ /**
get dateString(): string { * (YYMMDD)
return this.value.slice(1, 7); */
} get dateString(): string {
return this.value.slice(1, 7);
/** }
*
*/ /**
get dailySequence(): number { *
return parseInt(this.value.slice(7), 10); */
} get dailySequence(): number {
return parseInt(this.value.slice(7), 10);
equals(other: AccountSequence): boolean { }
return this.value === other.value;
} equals(other: AccountSequence): boolean {
return this.value === other.value;
toString(): string { }
return this.value;
} toString(): string {
} return this.value;
}
}

View File

@ -1,21 +1,21 @@
export class DeviceInfo { export class DeviceInfo {
private _lastActiveAt: Date; private _lastActiveAt: Date;
constructor( constructor(
public readonly deviceId: string, public readonly deviceId: string,
public readonly deviceName: string, public readonly deviceName: string,
public readonly addedAt: Date, public readonly addedAt: Date,
lastActiveAt: Date, lastActiveAt: Date,
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
) { ) {
this._lastActiveAt = lastActiveAt; this._lastActiveAt = lastActiveAt;
} }
get lastActiveAt(): Date { get lastActiveAt(): Date {
return this._lastActiveAt; return this._lastActiveAt;
} }
updateActivity(): void { updateActivity(): void {
this._lastActiveAt = new Date(); this._lastActiveAt = new Date();
} }
} }

View File

@ -1,5 +1,11 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; import {
createHash,
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'crypto';
import * as bip39 from '@scure/bip39'; import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english'; import { wordlist } from '@scure/bip39/wordlists/english';
@ -144,7 +150,9 @@ export class DeviceInfo {
} }
get deviceModel(): string | undefined { get deviceModel(): string | undefined {
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined; return (this._deviceInfo.model || this._deviceInfo.deviceModel) as
| string
| undefined;
} }
get osVersion(): string | undefined { get osVersion(): string | undefined {
@ -188,13 +196,27 @@ export class KYCInfo {
if (!realName || realName.length < 2) { if (!realName || realName.length < 2) {
throw new DomainError('真实姓名不合法'); throw new DomainError('真实姓名不合法');
} }
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) { if (
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
idCardNumber,
)
) {
throw new DomainError('身份证号格式错误'); throw new DomainError('身份证号格式错误');
} }
} }
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo { static create(params: {
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl); realName: string;
idCardNumber: string;
idCardFrontUrl: string;
idCardBackUrl: string;
}): KYCInfo {
return new KYCInfo(
params.realName,
params.idCardNumber,
params.idCardFrontUrl,
params.idCardBackUrl,
);
} }
maskedIdCardNumber(): string { maskedIdCardNumber(): string {
@ -255,7 +277,11 @@ export class MnemonicEncryption {
static decrypt(encryptedData: string, key: string): string { static decrypt(encryptedData: string, key: string): string {
const { encrypted, authTag, iv } = JSON.parse(encryptedData); const { encrypted, authTag, iv } = JSON.parse(encryptedData);
const derivedKey = this.deriveKey(key); const derivedKey = this.deriveKey(key);
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex')); const decipher = createDecipheriv(
'aes-256-gcm',
derivedKey,
Buffer.from(iv, 'hex'),
);
decipher.setAuthTag(Buffer.from(authTag, 'hex')); decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8'); let decrypted = decipher.update(encrypted, 'hex', 'utf8');

View File

@ -1,25 +1,39 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
export class KYCInfo { export class KYCInfo {
constructor( constructor(
public readonly realName: string, public readonly realName: string,
public readonly idCardNumber: string, public readonly idCardNumber: string,
public readonly idCardFrontUrl: string, public readonly idCardFrontUrl: string,
public readonly idCardBackUrl: string, public readonly idCardBackUrl: string,
) { ) {
if (!realName || realName.length < 2) { if (!realName || realName.length < 2) {
throw new DomainError('真实姓名不合法'); throw new DomainError('真实姓名不合法');
} }
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) { if (
throw new DomainError('身份证号格式错误'); !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
} idCardNumber,
} )
) {
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo { throw new DomainError('身份证号格式错误');
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl); }
} }
maskedIdCardNumber(): string { static create(params: {
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2'); realName: string;
} idCardNumber: string;
} idCardFrontUrl: string;
idCardBackUrl: string;
}): KYCInfo {
return new KYCInfo(
params.realName,
params.idCardNumber,
params.idCardFrontUrl,
params.idCardBackUrl,
);
}
maskedIdCardNumber(): string {
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
}
}

View File

@ -1,114 +1,118 @@
import { Mnemonic } from './index'; import { Mnemonic } from './index';
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
describe('Mnemonic ValueObject', () => { describe('Mnemonic ValueObject', () => {
describe('generate', () => { describe('generate', () => {
it('应该生成有效的12个单词助记词', () => { it('应该生成有效的12个单词助记词', () => {
const mnemonic = Mnemonic.generate(); const mnemonic = Mnemonic.generate();
expect(mnemonic).toBeDefined(); expect(mnemonic).toBeDefined();
expect(mnemonic.value).toBeDefined(); expect(mnemonic.value).toBeDefined();
const words = mnemonic.getWords(); const words = mnemonic.getWords();
expect(words).toHaveLength(12); expect(words).toHaveLength(12);
expect(words.every(word => word.length > 0)).toBe(true); expect(words.every((word) => word.length > 0)).toBe(true);
}); });
it('生成的助记词应该能转换为 seed', () => { it('生成的助记词应该能转换为 seed', () => {
const mnemonic = Mnemonic.generate(); const mnemonic = Mnemonic.generate();
const seed = mnemonic.toSeed(); const seed = mnemonic.toSeed();
expect(seed).toBeDefined(); expect(seed).toBeDefined();
expect(seed).toBeInstanceOf(Uint8Array); expect(seed).toBeInstanceOf(Uint8Array);
expect(seed.length).toBeGreaterThan(0); expect(seed.length).toBeGreaterThan(0);
}); });
it('每次生成的助记词应该不同', () => { it('每次生成的助记词应该不同', () => {
const mnemonic1 = Mnemonic.generate(); const mnemonic1 = Mnemonic.generate();
const mnemonic2 = Mnemonic.generate(); const mnemonic2 = Mnemonic.generate();
expect(mnemonic1.value).not.toBe(mnemonic2.value); expect(mnemonic1.value).not.toBe(mnemonic2.value);
}); });
}); });
describe('create', () => { describe('create', () => {
it('应该接受有效的助记词字符串', () => { it('应该接受有效的助记词字符串', () => {
const validMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const validMnemonic =
const mnemonic = Mnemonic.create(validMnemonic); 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const mnemonic = Mnemonic.create(validMnemonic);
expect(mnemonic.value).toBe(validMnemonic);
}); expect(mnemonic.value).toBe(validMnemonic);
});
it('应该拒绝无效的助记词', () => {
const invalidMnemonic = 'invalid invalid invalid'; it('应该拒绝无效的助记词', () => {
const invalidMnemonic = 'invalid invalid invalid';
expect(() => {
Mnemonic.create(invalidMnemonic); expect(() => {
}).toThrow(DomainError); Mnemonic.create(invalidMnemonic);
}); }).toThrow(DomainError);
});
it('应该拒绝空字符串', () => {
expect(() => { it('应该拒绝空字符串', () => {
Mnemonic.create(''); expect(() => {
}).toThrow(DomainError); Mnemonic.create('');
}); }).toThrow(DomainError);
});
it('应该拒绝非英文单词', () => {
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词'; it('应该拒绝非英文单词', () => {
const invalidMnemonic =
expect(() => { '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
Mnemonic.create(invalidMnemonic);
}).toThrow(DomainError); expect(() => {
}); Mnemonic.create(invalidMnemonic);
}); }).toThrow(DomainError);
});
describe('getWords', () => { });
it('应该返回单词数组', () => {
const mnemonic = Mnemonic.generate(); describe('getWords', () => {
const words = mnemonic.getWords(); it('应该返回单词数组', () => {
const mnemonic = Mnemonic.generate();
expect(Array.isArray(words)).toBe(true); const words = mnemonic.getWords();
expect(words.length).toBe(12);
}); expect(Array.isArray(words)).toBe(true);
}); expect(words.length).toBe(12);
});
describe('toSeed', () => { });
it('相同的助记词应该生成相同的 seed', () => {
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; describe('toSeed', () => {
const mnemonic1 = Mnemonic.create(mnemonicStr); it('相同的助记词应该生成相同的 seed', () => {
const mnemonic2 = Mnemonic.create(mnemonicStr); const mnemonicStr =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed1 = mnemonic1.toSeed(); const mnemonic1 = Mnemonic.create(mnemonicStr);
const seed2 = mnemonic2.toSeed(); const mnemonic2 = Mnemonic.create(mnemonicStr);
expect(seed1).toEqual(seed2); const seed1 = mnemonic1.toSeed();
}); const seed2 = mnemonic2.toSeed();
it('不同的助记词应该生成不同的 seed', () => { expect(seed1).toEqual(seed2);
const mnemonic1 = Mnemonic.generate(); });
const mnemonic2 = Mnemonic.generate();
it('不同的助记词应该生成不同的 seed', () => {
const seed1 = mnemonic1.toSeed(); const mnemonic1 = Mnemonic.generate();
const seed2 = mnemonic2.toSeed(); const mnemonic2 = Mnemonic.generate();
expect(seed1).not.toEqual(seed2); const seed1 = mnemonic1.toSeed();
}); const seed2 = mnemonic2.toSeed();
});
expect(seed1).not.toEqual(seed2);
describe('equals', () => { });
it('相同的助记词应该相等', () => { });
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const mnemonic1 = Mnemonic.create(mnemonicStr); describe('equals', () => {
const mnemonic2 = Mnemonic.create(mnemonicStr); it('相同的助记词应该相等', () => {
const mnemonicStr =
expect(mnemonic1.equals(mnemonic2)).toBe(true); 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
}); const mnemonic1 = Mnemonic.create(mnemonicStr);
const mnemonic2 = Mnemonic.create(mnemonicStr);
it('不同的助记词应该不相等', () => {
const mnemonic1 = Mnemonic.generate(); expect(mnemonic1.equals(mnemonic2)).toBe(true);
const mnemonic2 = Mnemonic.generate(); });
expect(mnemonic1.equals(mnemonic2)).toBe(false); it('不同的助记词应该不相等', () => {
}); const mnemonic1 = Mnemonic.generate();
}); const mnemonic2 = Mnemonic.generate();
});
expect(mnemonic1.equals(mnemonic2)).toBe(false);
});
});
});

View File

@ -1,32 +1,32 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
import * as bip39 from '@scure/bip39'; import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english'; import { wordlist } from '@scure/bip39/wordlists/english';
export class Mnemonic { export class Mnemonic {
constructor(public readonly value: string) { constructor(public readonly value: string) {
if (!bip39.validateMnemonic(value, wordlist)) { if (!bip39.validateMnemonic(value, wordlist)) {
throw new DomainError('助记词格式错误'); throw new DomainError('助记词格式错误');
} }
} }
static generate(): Mnemonic { static generate(): Mnemonic {
const mnemonic = bip39.generateMnemonic(wordlist, 128); const mnemonic = bip39.generateMnemonic(wordlist, 128);
return new Mnemonic(mnemonic); return new Mnemonic(mnemonic);
} }
static create(value: string): Mnemonic { static create(value: string): Mnemonic {
return new Mnemonic(value); return new Mnemonic(value);
} }
toSeed(): Uint8Array { toSeed(): Uint8Array {
return bip39.mnemonicToSeedSync(this.value); return bip39.mnemonicToSeedSync(this.value);
} }
getWords(): string[] { getWords(): string[] {
return this.value.split(' '); return this.value.split(' ');
} }
equals(other: Mnemonic): boolean { equals(other: Mnemonic): boolean {
return this.value === other.value; return this.value === other.value;
} }
} }

View File

@ -1,90 +1,90 @@
import { PhoneNumber } from './index'; import { PhoneNumber } from './index';
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
describe('PhoneNumber ValueObject', () => { describe('PhoneNumber ValueObject', () => {
describe('create', () => { describe('create', () => {
it('应该接受有效的中国手机号', () => { it('应该接受有效的中国手机号', () => {
const validPhones = [ const validPhones = [
'13800138000', '13800138000',
'13912345678', '13912345678',
'15800001111', '15800001111',
'18600002222', '18600002222',
'19900003333', '19900003333',
]; ];
validPhones.forEach(phone => { validPhones.forEach((phone) => {
const phoneNumber = PhoneNumber.create(phone); const phoneNumber = PhoneNumber.create(phone);
expect(phoneNumber.value).toBe(phone); expect(phoneNumber.value).toBe(phone);
}); });
}); });
it('应该拒绝无效的手机号格式', () => { it('应该拒绝无效的手机号格式', () => {
const invalidPhones = [ const invalidPhones = [
'12800138000', // 不是1开头 '12800138000', // 不是1开头
'1380013800', // 少于11位 '1380013800', // 少于11位
'138001380000', // 多于11位 '138001380000', // 多于11位
'10800138000', // 第二位不是3-9 '10800138000', // 第二位不是3-9
'abcdefghijk', // 非数字 'abcdefghijk', // 非数字
'', // 空字符串 '', // 空字符串
]; ];
invalidPhones.forEach(phone => { invalidPhones.forEach((phone) => {
expect(() => { expect(() => {
PhoneNumber.create(phone); PhoneNumber.create(phone);
}).toThrow(DomainError); }).toThrow(DomainError);
}); });
}); });
it('应该拒绝包含特殊字符的手机号', () => { it('应该拒绝包含特殊字符的手机号', () => {
const invalidPhones = [ const invalidPhones = [
'138-0013-8000', '138-0013-8000',
'138 0013 8000', '138 0013 8000',
'+8613800138000', '+8613800138000',
]; ];
invalidPhones.forEach(phone => { invalidPhones.forEach((phone) => {
expect(() => { expect(() => {
PhoneNumber.create(phone); PhoneNumber.create(phone);
}).toThrow(DomainError); }).toThrow(DomainError);
}); });
}); });
}); });
describe('masked', () => { describe('masked', () => {
it('应该正确掩码手机号', () => { it('应该正确掩码手机号', () => {
const phoneNumber = PhoneNumber.create('13800138000'); const phoneNumber = PhoneNumber.create('13800138000');
const masked = phoneNumber.masked(); const masked = phoneNumber.masked();
expect(masked).toBe('138****8000'); expect(masked).toBe('138****8000');
}); });
it('掩码后应该隐藏中间4位', () => { it('掩码后应该隐藏中间4位', () => {
const testCases = [ const testCases = [
{ input: '13912345678', expected: '139****5678' }, { input: '13912345678', expected: '139****5678' },
{ input: '15800001111', expected: '158****1111' }, { input: '15800001111', expected: '158****1111' },
{ input: '18600002222', expected: '186****2222' }, { input: '18600002222', expected: '186****2222' },
]; ];
testCases.forEach(({ input, expected }) => { testCases.forEach(({ input, expected }) => {
const phoneNumber = PhoneNumber.create(input); const phoneNumber = PhoneNumber.create(input);
expect(phoneNumber.masked()).toBe(expected); expect(phoneNumber.masked()).toBe(expected);
}); });
}); });
}); });
describe('equals', () => { describe('equals', () => {
it('相同的手机号应该相等', () => { it('相同的手机号应该相等', () => {
const phone1 = PhoneNumber.create('13800138000'); const phone1 = PhoneNumber.create('13800138000');
const phone2 = PhoneNumber.create('13800138000'); const phone2 = PhoneNumber.create('13800138000');
expect(phone1.equals(phone2)).toBe(true); expect(phone1.equals(phone2)).toBe(true);
}); });
it('不同的手机号应该不相等', () => { it('不同的手机号应该不相等', () => {
const phone1 = PhoneNumber.create('13800138000'); const phone1 = PhoneNumber.create('13800138000');
const phone2 = PhoneNumber.create('13912345678'); const phone2 = PhoneNumber.create('13912345678');
expect(phone1.equals(phone2)).toBe(false); expect(phone1.equals(phone2)).toBe(false);
}); });
}); });
}); });

View File

@ -1,21 +1,21 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
export class PhoneNumber { export class PhoneNumber {
constructor(public readonly value: string) { constructor(public readonly value: string) {
if (!/^1[3-9]\d{9}$/.test(value)) { if (!/^1[3-9]\d{9}$/.test(value)) {
throw new DomainError('手机号格式错误'); throw new DomainError('手机号格式错误');
} }
} }
static create(value: string): PhoneNumber { static create(value: string): PhoneNumber {
return new PhoneNumber(value); return new PhoneNumber(value);
} }
equals(other: PhoneNumber): boolean { equals(other: PhoneNumber): boolean {
return this.value === other.value; return this.value === other.value;
} }
masked(): string { masked(): string {
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
} }
} }

View File

@ -1,29 +1,29 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
export class ReferralCode { export class ReferralCode {
constructor(public readonly value: string) { constructor(public readonly value: string) {
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字) // 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
if (!/^[A-Z0-9]{6,20}$/.test(value)) { if (!/^[A-Z0-9]{6,20}$/.test(value)) {
throw new DomainError('推荐码格式错误'); throw new DomainError('推荐码格式错误');
} }
} }
static generate(): ReferralCode { static generate(): ReferralCode {
// 生成6位随机推荐码identity-service 本地生成) // 生成6位随机推荐码identity-service 本地生成)
// 注referral-service 会生成10位的推荐码两者都兼容 // 注referral-service 会生成10位的推荐码两者都兼容
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = ''; let code = '';
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length)); code += chars.charAt(Math.floor(Math.random() * chars.length));
} }
return new ReferralCode(code); return new ReferralCode(code);
} }
static create(value: string): ReferralCode { static create(value: string): ReferralCode {
return new ReferralCode(value.toUpperCase()); return new ReferralCode(value.toUpperCase());
} }
equals(other: ReferralCode): boolean { equals(other: ReferralCode): boolean {
return this.value === other.value; return this.value === other.value;
} }
} }

View File

@ -26,7 +26,7 @@ export interface VerifyMnemonicResult {
} }
export interface VerifyMnemonicByAccountParams { export interface VerifyMnemonicByAccountParams {
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
mnemonic: string; mnemonic: string;
} }
@ -60,8 +60,12 @@ export class BlockchainClientService {
/** /**
* *
*/ */
async verifyMnemonic(params: VerifyMnemonicParams): Promise<VerifyMnemonicResult> { async verifyMnemonic(
this.logger.log(`Verifying mnemonic against ${params.expectedAddresses.length} addresses`); params: VerifyMnemonicParams,
): Promise<VerifyMnemonicResult> {
this.logger.log(
`Verifying mnemonic against ${params.expectedAddresses.length} addresses`,
);
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
@ -78,7 +82,9 @@ export class BlockchainClientService {
), ),
); );
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`); this.logger.log(
`Mnemonic verification result: valid=${response.data.valid}`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
this.logger.error('Failed to verify mnemonic', error); this.logger.error('Failed to verify mnemonic', error);
@ -89,7 +95,9 @@ export class BlockchainClientService {
/** /**
* *
*/ */
async verifyMnemonicByAccount(params: VerifyMnemonicByAccountParams): Promise<VerifyMnemonicHashResult> { async verifyMnemonicByAccount(
params: VerifyMnemonicByAccountParams,
): Promise<VerifyMnemonicHashResult> {
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`); this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
try { try {
@ -107,7 +115,9 @@ export class BlockchainClientService {
), ),
); );
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`); this.logger.log(
`Mnemonic verification result: valid=${response.data.valid}`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
this.logger.error('Failed to verify mnemonic', error); this.logger.error('Failed to verify mnemonic', error);
@ -133,7 +143,9 @@ export class BlockchainClientService {
), ),
); );
this.logger.log(`Derived ${response.data.addresses.length} addresses from mnemonic`); this.logger.log(
`Derived ${response.data.addresses.length} addresses from mnemonic`,
);
return response.data.addresses; return response.data.addresses;
} catch (error) { } catch (error) {
this.logger.error('Failed to derive addresses from mnemonic', error); this.logger.error('Failed to derive addresses from mnemonic', error);
@ -145,7 +157,9 @@ export class BlockchainClientService {
* *
*/ */
async markMnemonicBackedUp(accountSequence: string): Promise<void> { async markMnemonicBackedUp(accountSequence: string): Promise<void> {
this.logger.log(`Marking mnemonic as backed up for account ${accountSequence}`); this.logger.log(
`Marking mnemonic as backed up for account ${accountSequence}`,
);
try { try {
await firstValueFrom( await firstValueFrom(
@ -159,7 +173,9 @@ export class BlockchainClientService {
), ),
); );
this.logger.log(`Mnemonic marked as backed up for account ${accountSequence}`); this.logger.log(
`Mnemonic marked as backed up for account ${accountSequence}`,
);
} catch (error) { } catch (error) {
this.logger.error('Failed to mark mnemonic as backed up', error); this.logger.error('Failed to mark mnemonic as backed up', error);
throw error; throw error;
@ -169,8 +185,13 @@ export class BlockchainClientService {
/** /**
* *
*/ */
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> { async revokeMnemonic(
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`); accountSequence: string,
reason: string,
): Promise<{ success: boolean; message: string }> {
this.logger.log(
`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`,
);
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
@ -184,7 +205,9 @@ export class BlockchainClientService {
), ),
); );
this.logger.log(`Mnemonic revoke result: success=${response.data.success}`); this.logger.log(
`Mnemonic revoke result: success=${response.data.success}`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
this.logger.error('Failed to revoke mnemonic', error); this.logger.error('Failed to revoke mnemonic', error);

View File

@ -1,3 +1,3 @@
export * from './mpc.module'; export * from './mpc.module';
export * from './mpc-client.service'; export * from './mpc-client.service';
export * from './mpc-wallet.service'; export * from './mpc-wallet.service';

View File

@ -1,250 +1,286 @@
/** /**
* MPC Wallet Service * MPC Wallet Service
* *
* 使 MPC 2-of-3 * 使 MPC 2-of-3
* *
* *
* 调用路径: identity-service mpc-service mpc-system * 调用路径: identity-service mpc-service mpc-system
*/ */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { MpcClientService } from './mpc-client.service'; import { MpcClientService } from './mpc-client.service';
export interface MpcWalletGenerationParams { export interface MpcWalletGenerationParams {
userId: string; userId: string;
username: string; // 用户名 (用于 MPC keygen) username: string; // 用户名 (用于 MPC keygen)
deviceId: string; deviceId: string;
} }
export interface ChainWalletInfo { export interface ChainWalletInfo {
chainType: 'KAVA' | 'DST' | 'BSC'; chainType: 'KAVA' | 'DST' | 'BSC';
address: string; address: string;
publicKey: string; publicKey: string;
addressDigest: string; addressDigest: string;
signature: string; // 64 bytes hex (R + S) signature: string; // 64 bytes hex (R + S)
} }
export interface MpcWalletGenerationResult { export interface MpcWalletGenerationResult {
publicKey: string; // MPC 公钥 publicKey: string; // MPC 公钥
delegateShare: string; // delegate share (加密的用户分片) delegateShare: string; // delegate share (加密的用户分片)
serverParties: string[]; // 服务器 party IDs serverParties: string[]; // 服务器 party IDs
wallets: ChainWalletInfo[]; // 三条链的钱包信息 wallets: ChainWalletInfo[]; // 三条链的钱包信息
sessionId: string; // MPC 会话ID sessionId: string; // MPC 会话ID
} }
@Injectable() @Injectable()
export class MpcWalletService { export class MpcWalletService {
private readonly logger = new Logger(MpcWalletService.name); private readonly logger = new Logger(MpcWalletService.name);
// 三条链的地址生成配置 // 三条链的地址生成配置
private readonly chainConfigs = { private readonly chainConfigs = {
BSC: { BSC: {
name: 'Binance Smart Chain', name: 'Binance Smart Chain',
prefix: '0x', prefix: '0x',
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链 derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
addressType: 'evm' as const, addressType: 'evm' as const,
}, },
KAVA: { KAVA: {
name: 'Kava EVM', name: 'Kava EVM',
prefix: '0x', prefix: '0x',
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址 derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
addressType: 'evm' as const, addressType: 'evm' as const,
}, },
DST: { DST: {
name: 'Durian Star Token', name: 'Durian Star Token',
prefix: 'dst', // Cosmos Bech32 前缀 prefix: 'dst', // Cosmos Bech32 前缀
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径 derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
addressType: 'cosmos' as const, addressType: 'cosmos' as const,
}, },
}; };
constructor( constructor(private readonly mpcClient: MpcClientService) {}
private readonly mpcClient: MpcClientService,
) {} /**
* 使 MPC 2-of-3
/** *
* 使 MPC 2-of-3 * :
* * 1. MPC (2-of-3)
* : * 2.
* 1. MPC (2-of-3) * 3.
* 2. * 4. 使 MPC
* 3. * 5.
* 4. 使 MPC */
* 5. async generateMpcWallet(
*/ params: MpcWalletGenerationParams,
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> { ): Promise<MpcWalletGenerationResult> {
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`); this.logger.log(
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
// Step 1: 生成 MPC 密钥 );
const keygenResult = await this.mpcClient.executeKeygen({
sessionId: this.mpcClient.generateSessionId(), // Step 1: 生成 MPC 密钥
username: params.username, const keygenResult = await this.mpcClient.executeKeygen({
threshold: 1, // t in t-of-n (2-of-3 means t=1) sessionId: this.mpcClient.generateSessionId(),
totalParties: 3, username: params.username,
requireDelegate: true, threshold: 1, // t in t-of-n (2-of-3 means t=1)
}); totalParties: 3,
requireDelegate: true,
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`); });
// Step 2: 从公钥派生三条链的地址 this.logger.log(
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey); `MPC keygen completed: publicKey=${keygenResult.publicKey}`,
);
// Step 3: 计算地址摘要
const addressDigest = this.computeAddressDigest(walletAddresses); // Step 2: 从公钥派生三条链的地址
const walletAddresses = await this.deriveChainAddresses(
// Step 4: 使用 MPC 签名对摘要进行签名 keygenResult.publicKey,
const signingResult = await this.mpcClient.executeSigning({ );
username: params.username,
messageHash: addressDigest, // Step 3: 计算地址摘要
}); const addressDigest = this.computeAddressDigest(walletAddresses);
this.logger.log(`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`); // Step 4: 使用 MPC 签名对摘要进行签名
const signingResult = await this.mpcClient.executeSigning({
// Step 5: 构建钱包信息 username: params.username,
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({ messageHash: addressDigest,
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC', });
address: wa.address,
publicKey: keygenResult.publicKey, this.logger.log(
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType), `MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`,
signature: signingResult.signature, );
}));
// Step 5: 构建钱包信息
return { const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
publicKey: keygenResult.publicKey, chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
delegateShare: keygenResult.delegateShare.encryptedShare, address: wa.address,
serverParties: keygenResult.serverParties, publicKey: keygenResult.publicKey,
wallets, addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
sessionId: keygenResult.sessionId, signature: signingResult.signature,
}; }));
}
return {
/** publicKey: keygenResult.publicKey,
* delegateShare: keygenResult.delegateShare.encryptedShare,
* serverParties: keygenResult.serverParties,
* wallets,
* sessionId: keygenResult.sessionId,
* @param address };
* @param chainType }
* @param publicKey (hex)
* @param signature (64 bytes hex: R + S) /**
*/ *
async verifyWalletSignature( *
address: string, *
chainType: string, *
publicKey: string, * @param address
signature: string, * @param chainType
): Promise<boolean> { * @param publicKey (hex)
try { * @param signature (64 bytes hex: R + S)
const { ethers } = await import('ethers'); */
async verifyWalletSignature(
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex address: string,
if (signature.length !== 128) { chainType: string,
this.logger.error(`Invalid signature length: ${signature.length}, expected 128`); publicKey: string,
return false; signature: string,
} ): Promise<boolean> {
try {
const r = '0x' + signature.slice(0, 64); const { ethers } = await import('ethers');
const s = '0x' + signature.slice(64, 128);
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
// 计算地址摘要 if (signature.length !== 128) {
const digest = this.computeSingleAddressDigest(address, chainType); this.logger.error(
const digestBytes = Buffer.from(digest, 'hex'); `Invalid signature length: ${signature.length}, expected 128`,
);
// 尝试两种 recovery id return false;
for (const v of [27, 28]) { }
try {
const sig = ethers.Signature.from({ r, s, v }); const r = '0x' + signature.slice(0, 64);
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); const s = '0x' + signature.slice(64, 128);
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
// 计算地址摘要
if (compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase()) { const digest = this.computeSingleAddressDigest(address, chainType);
return true; const digestBytes = Buffer.from(digest, 'hex');
}
} catch { // 尝试两种 recovery id
// 尝试下一个 v 值 for (const v of [27, 28]) {
} try {
} const sig = ethers.Signature.from({ r, s, v });
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
return false; digestBytes,
} catch (error) { sig,
this.logger.error(`Signature verification failed: ${error.message}`); );
return false; const compressedRecovered = ethers.SigningKey.computePublicKey(
} recoveredPubKey,
} true,
);
/**
* MPC if (
* compressedRecovered.slice(2).toLowerCase() ===
* - BSC/KAVA: EVM (keccak256) publicKey.toLowerCase()
* - DST: Cosmos Bech32 (ripemd160(sha256)) ) {
*/ return true;
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> { }
const { ethers } = await import('ethers'); } catch {
const { bech32 } = await import('bech32'); // 尝试下一个 v 值
}
// MPC 公钥 (压缩格式33 bytes) }
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex'); return false;
} catch (error) {
// 解压公钥 (如果是压缩格式) this.logger.error(`Signature verification failed: ${error.message}`);
let uncompressedPubKey: string; return false;
if (pubKeyHex.length === 68) { }
// 压缩格式 (33 bytes = 66 hex chars + 0x) }
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
} else { /**
uncompressedPubKey = pubKeyHex; * MPC
} *
* - BSC/KAVA: EVM (keccak256)
// ===== EVM 地址派生 (BSC, KAVA) ===== * - DST: Cosmos Bech32 (ripemd160(sha256))
// 地址 = keccak256(公钥[1:])[12:] */
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀 private async deriveChainAddresses(
const addressHash = ethers.keccak256(pubKeyBytes); publicKey: string,
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40)); ): Promise<{ chainType: string; address: string }[]> {
const { ethers } = await import('ethers');
// ===== Cosmos 地址派生 (DST) ===== const { bech32 } = await import('bech32');
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest(); // MPC 公钥 (压缩格式33 bytes)
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest(); const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash)); const compressedPubKeyBytes = Buffer.from(
pubKeyHex.replace('0x', ''),
return [ 'hex',
{ chainType: 'BSC', address: evmAddress }, );
{ chainType: 'KAVA', address: evmAddress },
{ chainType: 'DST', address: dstAddress }, // 解压公钥 (如果是压缩格式)
]; let uncompressedPubKey: string;
} if (pubKeyHex.length === 68) {
// 压缩格式 (33 bytes = 66 hex chars + 0x)
/** uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
* } else {
* uncompressedPubKey = pubKeyHex;
* digest = SHA256(BSC地址 + KAVA地址 + DST地址) }
*/
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string { // ===== EVM 地址派生 (BSC, KAVA) =====
// 按链类型排序以确保一致性 // 地址 = keccak256(公钥[1:])[12:]
const sortedAddresses = [...addresses].sort((a, b) => const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
a.chainType.localeCompare(b.chainType), const addressHash = ethers.keccak256(pubKeyBytes);
); const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
// 拼接地址 // ===== Cosmos 地址派生 (DST) =====
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join(''); // 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
const sha256Hash = createHash('sha256')
// 计算 SHA256 摘要 .update(compressedPubKeyBytes)
return createHash('sha256').update(concatenated).digest('hex'); .digest();
} const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
const dstAddress = bech32.encode(
/** this.chainConfigs.DST.prefix,
* bech32.toWords(ripemd160Hash),
*/ );
private computeSingleAddressDigest(address: string, chainType: string): string {
const message = `${chainType}:${address.toLowerCase()}`; return [
return createHash('sha256').update(message).digest('hex'); { chainType: 'BSC', address: evmAddress },
} { chainType: 'KAVA', address: evmAddress },
{ chainType: 'DST', address: dstAddress },
/** ];
* }
*/
getSupportedChains(): string[] { /**
return Object.keys(this.chainConfigs); *
} *
} * digest = SHA256(BSC地址 + KAVA地址 + DST地址)
*/
private computeAddressDigest(
addresses: { chainType: string; address: string }[],
): string {
// 按链类型排序以确保一致性
const sortedAddresses = [...addresses].sort((a, b) =>
a.chainType.localeCompare(b.chainType),
);
// 拼接地址
const concatenated = sortedAddresses
.map((a) => a.address.toLowerCase())
.join('');
// 计算 SHA256 摘要
return createHash('sha256').update(concatenated).digest('hex');
}
/**
*
*/
private computeSingleAddressDigest(
address: string,
chainType: string,
): string {
const message = `${chainType}:${address.toLowerCase()}`;
return createHash('sha256').update(message).digest('hex');
}
/**
*
*/
getSupportedChains(): string[] {
return Object.keys(this.chainConfigs);
}
}

View File

@ -1,18 +1,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { MpcWalletService } from './mpc-wallet.service'; import { MpcWalletService } from './mpc-wallet.service';
import { MpcClientService } from './mpc-client.service'; import { MpcClientService } from './mpc-client.service';
import { KafkaModule } from '../../kafka/kafka.module'; import { KafkaModule } from '../../kafka/kafka.module';
@Module({ @Module({
imports: [ imports: [
HttpModule.register({ HttpModule.register({
timeout: 300000, // MPC 操作可能需要较长时间 timeout: 300000, // MPC 操作可能需要较长时间
maxRedirects: 5, maxRedirects: 5,
}), }),
KafkaModule, // 用于事件驱动模式 KafkaModule, // 用于事件驱动模式
], ],
providers: [MpcWalletService, MpcClientService], providers: [MpcWalletService, MpcClientService],
exports: [MpcWalletService, MpcClientService], exports: [MpcWalletService, MpcClientService],
}) })
export class MpcModule {} export class MpcModule {}

View File

@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
@Module({ @Module({
providers: [SmsService], providers: [SmsService],
exports: [SmsService], exports: [SmsService],
}) })
export class SmsModule {} export class SmsModule {}

View File

@ -1,256 +1,293 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525'; import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import * as $OpenApi from '@alicloud/openapi-client'; import * as $OpenApi from '@alicloud/openapi-client';
import * as $Util from '@alicloud/tea-util'; import * as $Util from '@alicloud/tea-util';
export interface SmsSendResult { export interface SmsSendResult {
success: boolean; success: boolean;
requestId?: string; requestId?: string;
bizId?: string; bizId?: string;
code?: string; code?: string;
message?: string; message?: string;
} }
@Injectable() @Injectable()
export class SmsService implements OnModuleInit { export class SmsService implements OnModuleInit {
private readonly logger = new Logger(SmsService.name); private readonly logger = new Logger(SmsService.name);
private client: Dysmsapi20170525 | null = null; private client: Dysmsapi20170525 | null = null;
private readonly signName: string; private readonly signName: string;
private readonly templateCode: string; private readonly templateCode: string;
private readonly enabled: boolean; private readonly enabled: boolean;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
const smsConfig = this.configService.get('smsConfig') || {}; const smsConfig = this.configService.get('smsConfig') || {};
const aliyunConfig = smsConfig.aliyun || {}; const aliyunConfig = smsConfig.aliyun || {};
this.signName = aliyunConfig.signName || this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后'); this.signName =
this.templateCode = aliyunConfig.templateCode || this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', ''); aliyunConfig.signName ||
this.enabled = smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true'; this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
} this.templateCode =
aliyunConfig.templateCode ||
async onModuleInit() { this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
await this.initClient(); this.enabled =
} smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
}
private async initClient(): Promise<void> {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID'); async onModuleInit() {
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET'); await this.initClient();
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com'); }
if (!accessKeyId || !accessKeySecret) { private async initClient(): Promise<void> {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式'); const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
return; const accessKeySecret = this.configService.get<string>(
} 'ALIYUN_ACCESS_KEY_SECRET',
);
try { const endpoint = this.configService.get<string>(
const config = new $OpenApi.Config({ 'ALIYUN_SMS_ENDPOINT',
accessKeyId, 'dysmsapi.aliyuncs.com',
accessKeySecret, );
endpoint,
}); if (!accessKeyId || !accessKeySecret) {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
this.client = new Dysmsapi20170525(config); return;
this.logger.log('阿里云 SMS 客户端初始化成功'); }
} catch (error) {
this.logger.error('阿里云 SMS 客户端初始化失败', error); try {
} const config = new $OpenApi.Config({
} accessKeyId,
accessKeySecret,
/** endpoint,
* });
*
* @param phoneNumber +86xxx this.client = new Dysmsapi20170525(config);
* @param code this.logger.log('阿里云 SMS 客户端初始化成功');
* @returns } catch (error) {
*/ this.logger.error('阿里云 SMS 客户端初始化失败', error);
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> { }
// 标准化手机号(去除 +86 前缀) }
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
/**
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`); *
*
// 开发环境或未启用时,使用模拟模式 * @param phoneNumber +86xxx
if (!this.enabled || !this.client) { * @param code
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`); * @returns
return { */
success: true, async sendVerificationCode(
requestId: 'mock-request-id', phoneNumber: string,
bizId: 'mock-biz-id', code: string,
code: 'OK', ): Promise<SmsSendResult> {
message: '模拟发送成功', // 标准化手机号(去除 +86 前缀)
}; const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
}
this.logger.log(
try { `[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({ );
phoneNumbers: normalizedPhone,
signName: this.signName, // 开发环境或未启用时,使用模拟模式
templateCode: this.templateCode, if (!this.enabled || !this.client) {
templateParam: JSON.stringify({ code }), this.logger.warn(
}); `[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
const runtime = new $Util.RuntimeOptions({ return {
connectTimeout: 10000, // 连接超时 10 秒 success: true,
readTimeout: 10000, // 读取超时 10 秒 requestId: 'mock-request-id',
}); bizId: 'mock-biz-id',
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime); code: 'OK',
message: '模拟发送成功',
const body = response.body; };
const result: SmsSendResult = { }
success: body?.code === 'OK',
requestId: body?.requestId, try {
bizId: body?.bizId, const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
code: body?.code, phoneNumbers: normalizedPhone,
message: body?.message, signName: this.signName,
}; templateCode: this.templateCode,
templateParam: JSON.stringify({ code }),
if (result.success) { });
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
} else { const runtime = new $Util.RuntimeOptions({
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`); connectTimeout: 10000, // 连接超时 10 秒
} readTimeout: 10000, // 读取超时 10 秒
});
return result; const response = await this.client.sendSmsWithOptions(
} catch (error: any) { sendSmsRequest,
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack); runtime,
);
// 解析阿里云错误
if (error.code) { const body = response.body;
return { const result: SmsSendResult = {
success: false, success: body?.code === 'OK',
code: error.code, requestId: body?.requestId,
message: error.message || '短信发送失败', bizId: body?.bizId,
}; code: body?.code,
} message: body?.message,
};
return {
success: false, if (result.success) {
code: 'UNKNOWN_ERROR', this.logger.log(
message: error.message || '短信发送失败', `[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
}; );
} } else {
} this.logger.error(
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
/** );
* }
*
* @param phoneNumber return result;
* @param templateCode } catch (error: any) {
* @param templateParam this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
* @returns
*/ // 解析阿里云错误
async sendSms( if (error.code) {
phoneNumber: string, return {
templateCode: string, success: false,
templateParam: Record<string, string>, code: error.code,
): Promise<SmsSendResult> { message: error.message || '短信发送失败',
const normalizedPhone = this.normalizePhoneNumber(phoneNumber); };
}
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`); return {
return { success: false,
success: true, code: 'UNKNOWN_ERROR',
requestId: 'mock-request-id', message: error.message || '短信发送失败',
code: 'OK', };
message: '模拟发送成功', }
}; }
}
/**
try { *
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({ *
phoneNumbers: normalizedPhone, * @param phoneNumber
signName: this.signName, * @param templateCode
templateCode, * @param templateParam
templateParam: JSON.stringify(templateParam), * @returns
}); */
async sendSms(
const runtime = new $Util.RuntimeOptions({ phoneNumber: string,
connectTimeout: 10000, // 连接超时 10 秒 templateCode: string,
readTimeout: 10000, // 读取超时 10 秒 templateParam: Record<string, string>,
}); ): Promise<SmsSendResult> {
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime); const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
const body = response.body; if (!this.enabled || !this.client) {
return { this.logger.warn(
success: body?.code === 'OK', `[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
requestId: body?.requestId, );
bizId: body?.bizId, return {
code: body?.code, success: true,
message: body?.message, requestId: 'mock-request-id',
}; code: 'OK',
} catch (error: any) { message: '模拟发送成功',
this.logger.error(`[SMS] 发送异常: ${error.message}`); };
return { }
success: false,
code: error.code || 'UNKNOWN_ERROR', try {
message: error.message || '短信发送失败', const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
}; phoneNumbers: normalizedPhone,
} signName: this.signName,
} templateCode,
templateParam: JSON.stringify(templateParam),
/** });
*
* const runtime = new $Util.RuntimeOptions({
* @param phoneNumber connectTimeout: 10000, // 连接超时 10 秒
* @param bizId ID readTimeout: 10000, // 读取超时 10 秒
* @param sendDate (yyyyMMdd ) });
*/ const response = await this.client.sendSmsWithOptions(
async querySendDetails( sendSmsRequest,
phoneNumber: string, runtime,
bizId: string, );
sendDate: string,
): Promise<any> { const body = response.body;
if (!this.client) { return {
this.logger.warn('[SMS] 客户端未初始化,无法查询'); success: body?.code === 'OK',
return null; requestId: body?.requestId,
} bizId: body?.bizId,
code: body?.code,
try { message: body?.message,
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({ };
phoneNumber: this.normalizePhoneNumber(phoneNumber), } catch (error: any) {
bizId, this.logger.error(`[SMS] 发送异常: ${error.message}`);
sendDate, return {
pageSize: 10, success: false,
currentPage: 1, code: error.code || 'UNKNOWN_ERROR',
}); message: error.message || '短信发送失败',
};
const runtime = new $Util.RuntimeOptions({ }
connectTimeout: 10000, // 连接超时 10 秒 }
readTimeout: 10000, // 读取超时 10 秒
}); /**
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime); *
*
return response.body; * @param phoneNumber
} catch (error: any) { * @param bizId ID
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`); * @param sendDate (yyyyMMdd )
return null; */
} async querySendDetails(
} phoneNumber: string,
bizId: string,
/** sendDate: string,
* ): Promise<any> {
*/ if (!this.client) {
private normalizePhoneNumber(phoneNumber: string): string { this.logger.warn('[SMS] 客户端未初始化,无法查询');
let normalized = phoneNumber.trim(); return null;
}
// 去除 +86 或 86 前缀
if (normalized.startsWith('+86')) { try {
normalized = normalized.substring(3); const querySendDetailsRequest =
} else if (normalized.startsWith('86') && normalized.length === 13) { new $Dysmsapi20170525.QuerySendDetailsRequest({
normalized = normalized.substring(2); phoneNumber: this.normalizePhoneNumber(phoneNumber),
} bizId,
sendDate,
return normalized; pageSize: 10,
} currentPage: 1,
});
/**
* const runtime = new $Util.RuntimeOptions({
*/ connectTimeout: 10000, // 连接超时 10 秒
private maskPhoneNumber(phoneNumber: string): string { readTimeout: 10000, // 读取超时 10 秒
if (phoneNumber.length < 7) { });
return phoneNumber; const response = await this.client.querySendDetailsWithOptions(
} querySendDetailsRequest,
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4); runtime,
} );
}
return response.body;
} catch (error: any) {
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
return null;
}
}
/**
*
*/
private normalizePhoneNumber(phoneNumber: string): string {
let normalized = phoneNumber.trim();
// 去除 +86 或 86 前缀
if (normalized.startsWith('+86')) {
normalized = normalized.substring(3);
} else if (normalized.startsWith('86') && normalized.length === 13) {
normalized = normalized.substring(2);
}
return normalized;
}
/**
*
*/
private maskPhoneNumber(phoneNumber: string): string {
if (phoneNumber.length < 7) {
return phoneNumber;
}
return (
phoneNumber.substring(0, 3) +
'****' +
phoneNumber.substring(phoneNumber.length - 4)
);
}
}

View File

@ -30,14 +30,30 @@ export class StorageService implements OnModuleInit {
private publicUrl: string; private publicUrl: string;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
const endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost'); const endpoint = this.configService.get<string>(
'MINIO_ENDPOINT',
'localhost',
);
const port = this.configService.get<number>('MINIO_PORT', 9000); const port = this.configService.get<number>('MINIO_PORT', 9000);
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true'; const useSSL =
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin'); this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password'); 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.bucketAvatars = this.configService.get<string>(
this.publicUrl = this.configService.get<string>('MINIO_PUBLIC_URL', 'http://localhost:9000'); 'MINIO_BUCKET_AVATARS',
'avatars',
);
this.publicUrl = this.configService.get<string>(
'MINIO_PUBLIC_URL',
'http://localhost:9000',
);
this.client = new Minio.Client({ this.client = new Minio.Client({
endPoint: endpoint, endPoint: endpoint,
@ -83,7 +99,9 @@ export class StorageService implements OnModuleInit {
this.logger.log(`Bucket exists: ${bucketName}`); this.logger.log(`Bucket exists: ${bucketName}`);
} }
} catch (error) { } catch (error) {
this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`); this.logger.error(
`Failed to ensure bucket ${bucketName}: ${error.message}`,
);
// 不抛出异常允许服务启动MinIO可能暂时不可用 // 不抛出异常允许服务启动MinIO可能暂时不可用
} }
} }
@ -156,7 +174,7 @@ export class StorageService implements OnModuleInit {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// URL格式: http://host/bucket/key // URL格式: http://host/bucket/key
const pathParts = urlObj.pathname.split('/').filter(p => p); const pathParts = urlObj.pathname.split('/').filter((p) => p);
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) { if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
return pathParts.slice(1).join('/'); return pathParts.slice(1).join('/');
} }
@ -184,7 +202,13 @@ export class StorageService implements OnModuleInit {
* *
*/ */
isValidImageType(contentType: string): boolean { isValidImageType(contentType: string): boolean {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const validTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
];
return validTypes.includes(contentType); return validTypes.includes(contentType);
} }

View File

@ -1,65 +1,65 @@
import { Module, Global } from '@nestjs/common'; import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './persistence/prisma/prisma.service'; import { PrismaService } from './persistence/prisma/prisma.service';
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl'; import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl'; import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
import { UserAccountMapper } from './persistence/mappers/user-account.mapper'; import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
import { RedisService } from './redis/redis.service'; import { RedisService } from './redis/redis.service';
import { EventPublisherService } from './kafka/event-publisher.service'; import { EventPublisherService } from './kafka/event-publisher.service';
import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service'; import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service'; import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
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 { 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()
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
HttpModule.register({ HttpModule.register({
timeout: 300000, timeout: 300000,
maxRedirects: 5, maxRedirects: 5,
}), }),
], ],
providers: [ providers: [
PrismaService, PrismaService,
UserAccountRepositoryImpl, UserAccountRepositoryImpl,
{ {
provide: MPC_KEY_SHARE_REPOSITORY, provide: MPC_KEY_SHARE_REPOSITORY,
useClass: MpcKeyShareRepositoryImpl, useClass: MpcKeyShareRepositoryImpl,
}, },
UserAccountMapper, UserAccountMapper,
RedisService, RedisService,
EventPublisherService, EventPublisherService,
MpcEventConsumerService, MpcEventConsumerService,
BlockchainEventConsumerService, BlockchainEventConsumerService,
SmsService, SmsService,
// BlockchainClientService 调用 blockchain-service API // BlockchainClientService 调用 blockchain-service API
BlockchainClientService, BlockchainClientService,
MpcClientService, MpcClientService,
MpcWalletService, MpcWalletService,
StorageService, StorageService,
], ],
exports: [ exports: [
PrismaService, PrismaService,
UserAccountRepositoryImpl, UserAccountRepositoryImpl,
{ {
provide: MPC_KEY_SHARE_REPOSITORY, provide: MPC_KEY_SHARE_REPOSITORY,
useClass: MpcKeyShareRepositoryImpl, useClass: MpcKeyShareRepositoryImpl,
}, },
UserAccountMapper, UserAccountMapper,
RedisService, RedisService,
EventPublisherService, EventPublisherService,
MpcEventConsumerService, MpcEventConsumerService,
BlockchainEventConsumerService, BlockchainEventConsumerService,
SmsService, SmsService,
BlockchainClientService, BlockchainClientService,
MpcClientService, MpcClientService,
MpcWalletService, MpcWalletService,
StorageService, StorageService,
], ],
}) })
export class InfrastructureModule {} export class InfrastructureModule {}

View File

@ -5,7 +5,12 @@
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys. * Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
*/ */
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs'; import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
@ -22,15 +27,17 @@ export interface WalletAddressCreatedPayload {
address: string; address: string;
}[]; }[];
// 恢复助记词相关 // 恢复助记词相关
mnemonic?: string; // 12词助记词 (明文) mnemonic?: string; // 12词助记词 (明文)
encryptedMnemonic?: string; // 加密的助记词 encryptedMnemonic?: string; // 加密的助记词
mnemonicHash?: string; // 助记词哈希 mnemonicHash?: string; // 助记词哈希
} }
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>; export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
@Injectable() @Injectable()
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy { export class BlockchainEventConsumerService
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(BlockchainEventConsumerService.name); private readonly logger = new Logger(BlockchainEventConsumerService.name);
private kafka: Kafka; private kafka: Kafka;
private consumer: Consumer; private consumer: Consumer;
@ -41,15 +48,20 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async onModuleInit() { async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092']; const brokers = this.configService
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service'; .get<string>('KAFKA_BROKERS')
?.split(',') || ['localhost:9092'];
const clientId =
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const groupId = 'identity-service-blockchain-events'; const groupId = 'identity-service-blockchain-events';
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`); this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`); this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`); this.logger.log(`[INIT] GroupId: ${groupId}`);
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`); this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`); this.logger.log(
`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
);
// 企业级重试配置:指数退避,最多重试约 2.5 小时 // 企业级重试配置:指数退避,最多重试约 2.5 小时
this.kafka = new Kafka({ this.kafka = new Kafka({
@ -57,10 +69,10 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
brokers, brokers,
logLevel: logLevel.WARN, logLevel: logLevel.WARN,
retry: { retry: {
initialRetryTime: 1000, // 1 秒 initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟 maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次 retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子 multiplier: 2, // 指数退避因子
restartOnFailure: async () => true, restartOnFailure: async () => true,
}, },
}); });
@ -75,16 +87,26 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`); this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
await this.consumer.connect(); await this.consumer.connect();
this.isConnected = true; this.isConnected = true;
this.logger.log(`[CONNECT] Blockchain Event Kafka consumer connected successfully`); this.logger.log(
`[CONNECT] Blockchain Event Kafka consumer connected successfully`,
);
// Subscribe to blockchain topics // Subscribe to blockchain topics
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false }); await this.consumer.subscribe({
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`); topics: Object.values(BLOCKCHAIN_TOPICS),
fromBeginning: false,
});
this.logger.log(
`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
);
// Start consuming // Start consuming
await this.startConsuming(); await this.startConsuming();
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to connect Blockchain Event Kafka consumer`, error); this.logger.error(
`[ERROR] Failed to connect Blockchain Event Kafka consumer`,
error,
);
} }
} }
@ -98,16 +120,24 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
/** /**
* Register handler for wallet address created events * Register handler for wallet address created events
*/ */
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void { onWalletAddressCreated(
handler: BlockchainEventHandler<WalletAddressCreatedPayload>,
): void {
this.walletAddressCreatedHandler = handler; this.walletAddressCreatedHandler = handler;
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`); this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
} }
private async startConsuming(): Promise<void> { private async startConsuming(): Promise<void> {
await this.consumer.run({ await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => { eachMessage: async ({
topic,
partition,
message,
}: EachMessagePayload) => {
const offset = message.offset; const offset = message.offset;
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`); this.logger.log(
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
);
try { try {
const value = message.value?.toString(); const value = message.value?.toString();
@ -116,33 +146,53 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
return; return;
} }
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`); this.logger.log(
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
);
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
const payload = parsed.payload || parsed; const payload = parsed.payload || parsed;
const eventType = parsed.eventType || 'unknown'; const eventType = parsed.eventType || 'unknown';
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`); this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`); this.logger.log(
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
);
// Handle WalletAddressCreated events // Handle WalletAddressCreated events
if (eventType === 'blockchain.wallet.address.created' || topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED) { if (
eventType === 'blockchain.wallet.address.created' ||
topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED
) {
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`); this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
this.logger.log(`[HANDLE] userId: ${payload.userId}`); this.logger.log(`[HANDLE] userId: ${payload.userId}`);
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`); this.logger.log(
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`); `[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`,
);
this.logger.log(
`[HANDLE] addresses count: ${payload.addresses?.length}`,
);
if (this.walletAddressCreatedHandler) { if (this.walletAddressCreatedHandler) {
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload); await this.walletAddressCreatedHandler(
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`); payload as WalletAddressCreatedPayload,
);
this.logger.log(
`[HANDLE] WalletAddressCreated handler completed successfully`,
);
} else { } else {
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`); this.logger.warn(
`[HANDLE] No handler registered for WalletAddressCreated`,
);
} }
} else { } else {
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`); this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
} }
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Error processing blockchain event from ${topic}`, error); this.logger.error(
`[ERROR] Error processing blockchain event from ${topic}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
// This ensures messages are not marked as consumed until successfully processed // This ensures messages are not marked as consumed until successfully processed
throw error; throw error;

View File

@ -1,83 +1,85 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../persistence/prisma/prisma.service'; import { PrismaService } from '../persistence/prisma/prisma.service';
import { DomainEventMessage } from './event-publisher.service'; import { DomainEventMessage } from './event-publisher.service';
@Injectable() @Injectable()
export class DeadLetterService { export class DeadLetterService {
private readonly logger = new Logger(DeadLetterService.name); private readonly logger = new Logger(DeadLetterService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async saveFailedEvent( async saveFailedEvent(
topic: string, topic: string,
message: DomainEventMessage, message: DomainEventMessage,
error: Error, error: Error,
retryCount: number, retryCount: number,
): Promise<void> { ): Promise<void> {
await this.prisma.deadLetterEvent.create({ await this.prisma.deadLetterEvent.create({
data: { data: {
topic, topic,
eventId: message.eventId, eventId: message.eventId,
eventType: message.eventType, eventType: message.eventType,
aggregateId: message.aggregateId, aggregateId: message.aggregateId,
aggregateType: message.aggregateType, aggregateType: message.aggregateType,
payload: message.payload, payload: message.payload,
errorMessage: error.message, errorMessage: error.message,
errorStack: error.stack, errorStack: error.stack,
retryCount, retryCount,
createdAt: new Date(), createdAt: new Date(),
}, },
}); });
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`); this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
} }
async getFailedEvents(limit: number = 100): Promise<any[]> { async getFailedEvents(limit: number = 100): Promise<any[]> {
return this.prisma.deadLetterEvent.findMany({ return this.prisma.deadLetterEvent.findMany({
where: { processedAt: null }, where: { processedAt: null },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: limit, take: limit,
}); });
} }
async markAsProcessed(id: bigint): Promise<void> { async markAsProcessed(id: bigint): Promise<void> {
await this.prisma.deadLetterEvent.update({ await this.prisma.deadLetterEvent.update({
where: { id }, where: { id },
data: { processedAt: new Date() }, data: { processedAt: new Date() },
}); });
this.logger.log(`Dead letter event marked as processed: ${id}`); this.logger.log(`Dead letter event marked as processed: ${id}`);
} }
async incrementRetryCount(id: bigint): Promise<void> { async incrementRetryCount(id: bigint): Promise<void> {
await this.prisma.deadLetterEvent.update({ await this.prisma.deadLetterEvent.update({
where: { id }, where: { id },
data: { retryCount: { increment: 1 } }, data: { retryCount: { increment: 1 } },
}); });
} }
async getStatistics(): Promise<{ async getStatistics(): Promise<{
total: number; total: number;
pending: number; pending: number;
processed: number; processed: number;
byTopic: Record<string, number>; byTopic: Record<string, number>;
}> { }> {
const [total, pending, processed, byTopic] = await Promise.all([ const [total, pending, processed, byTopic] = await Promise.all([
this.prisma.deadLetterEvent.count(), this.prisma.deadLetterEvent.count(),
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }), this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }), this.prisma.deadLetterEvent.count({
this.prisma.deadLetterEvent.groupBy({ where: { processedAt: { not: null } },
by: ['topic'], }),
_count: true, this.prisma.deadLetterEvent.groupBy({
where: { processedAt: null }, by: ['topic'],
}), _count: true,
]); where: { processedAt: null },
}),
const topicStats: Record<string, number> = {}; ]);
for (const item of byTopic) {
topicStats[item.topic] = item._count; const topicStats: Record<string, number> = {};
} for (const item of byTopic) {
topicStats[item.topic] = item._count;
return { total, pending, processed, byTopic: topicStats }; }
}
} return { total, pending, processed, byTopic: topicStats };
}
}

View File

@ -1,237 +1,239 @@
import { Controller, Logger } from '@nestjs/common'; import { Controller, Logger } from '@nestjs/common';
import { import {
MessagePattern, MessagePattern,
Payload, Payload,
Ctx, Ctx,
KafkaContext, KafkaContext,
} from '@nestjs/microservices'; } from '@nestjs/microservices';
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service'; import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
@Controller() @Controller()
export class EventConsumerController { export class EventConsumerController {
private readonly logger = new Logger(EventConsumerController.name); private readonly logger = new Logger(EventConsumerController.name);
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED) @MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
async handleUserAccountCreated( async handleUserAccountCreated(
@Payload() message: DomainEventMessage, @Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext, @Ctx() context: KafkaContext,
): Promise<void> { ): Promise<void> {
const { offset } = context.getMessage(); const { offset } = context.getMessage();
const partition = context.getPartition(); const partition = context.getPartition();
this.logger.log( this.logger.log(
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, `Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
); );
try { try {
await this.processUserAccountCreated(message.payload); await this.processUserAccountCreated(message.payload);
this.logger.log( this.logger.log(
`Successfully processed UserAccountCreated: ${message.eventId}`, `Successfully processed UserAccountCreated: ${message.eventId}`,
); );
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process UserAccountCreated: ${message.eventId}`, `Failed to process UserAccountCreated: ${message.eventId}`,
error, error,
); );
throw error; throw error;
} }
} }
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED) @MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
async handleDeviceAdded( async handleDeviceAdded(
@Payload() message: DomainEventMessage, @Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext, @Ctx() context: KafkaContext,
): Promise<void> { ): Promise<void> {
const { offset } = context.getMessage(); const { offset } = context.getMessage();
const partition = context.getPartition(); const partition = context.getPartition();
this.logger.log( this.logger.log(
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, `Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
); );
try { try {
await this.processDeviceAdded(message.payload); await this.processDeviceAdded(message.payload);
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`); this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process DeviceAdded: ${message.eventId}`, `Failed to process DeviceAdded: ${message.eventId}`,
error, error,
); );
throw error; throw error;
} }
} }
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND) @MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
async handlePhoneBound( async handlePhoneBound(
@Payload() message: DomainEventMessage, @Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext, @Ctx() context: KafkaContext,
): Promise<void> { ): Promise<void> {
const { offset } = context.getMessage(); const { offset } = context.getMessage();
const partition = context.getPartition(); const partition = context.getPartition();
this.logger.log( this.logger.log(
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, `Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
); );
try { try {
await this.processPhoneBound(message.payload); await this.processPhoneBound(message.payload);
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`); this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process PhoneBound: ${message.eventId}`, `Failed to process PhoneBound: ${message.eventId}`,
error, error,
); );
throw error; throw error;
} }
} }
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED) @MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
async handleKYCSubmitted( async handleKYCSubmitted(
@Payload() message: DomainEventMessage, @Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext, @Ctx() context: KafkaContext,
): Promise<void> { ): Promise<void> {
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`); this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
try { try {
await this.processKYCSubmitted(message.payload); await this.processKYCSubmitted(message.payload);
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`); this.logger.log(
} catch (error) { `Successfully processed KYCSubmitted: ${message.eventId}`,
this.logger.error( );
`Failed to process KYCSubmitted: ${message.eventId}`, } catch (error) {
error, this.logger.error(
); `Failed to process KYCSubmitted: ${message.eventId}`,
throw error; error,
} );
} throw error;
}
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED) }
async handleKYCApproved(
@Payload() message: DomainEventMessage, @MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
@Ctx() context: KafkaContext, async handleKYCApproved(
): Promise<void> { @Payload() message: DomainEventMessage,
this.logger.log(`Received KYCApproved event: ${message.eventId}`); @Ctx() context: KafkaContext,
): Promise<void> {
try { this.logger.log(`Received KYCApproved event: ${message.eventId}`);
await this.processKYCApproved(message.payload);
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`); try {
} catch (error) { await this.processKYCApproved(message.payload);
this.logger.error( this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
`Failed to process KYCApproved: ${message.eventId}`, } catch (error) {
error, this.logger.error(
); `Failed to process KYCApproved: ${message.eventId}`,
throw error; error,
} );
} throw error;
}
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED) }
async handleKYCRejected(
@Payload() message: DomainEventMessage, @MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
@Ctx() context: KafkaContext, async handleKYCRejected(
): Promise<void> { @Payload() message: DomainEventMessage,
this.logger.log(`Received KYCRejected event: ${message.eventId}`); @Ctx() context: KafkaContext,
): Promise<void> {
try { this.logger.log(`Received KYCRejected event: ${message.eventId}`);
await this.processKYCRejected(message.payload);
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`); try {
} catch (error) { await this.processKYCRejected(message.payload);
this.logger.error( this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
`Failed to process KYCRejected: ${message.eventId}`, } catch (error) {
error, this.logger.error(
); `Failed to process KYCRejected: ${message.eventId}`,
throw error; error,
} );
} throw error;
}
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN) }
async handleAccountFrozen(
@Payload() message: DomainEventMessage, @MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
@Ctx() context: KafkaContext, async handleAccountFrozen(
): Promise<void> { @Payload() message: DomainEventMessage,
this.logger.log(`Received AccountFrozen event: ${message.eventId}`); @Ctx() context: KafkaContext,
): Promise<void> {
try { this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
await this.processAccountFrozen(message.payload);
this.logger.log( try {
`Successfully processed AccountFrozen: ${message.eventId}`, await this.processAccountFrozen(message.payload);
); this.logger.log(
} catch (error) { `Successfully processed AccountFrozen: ${message.eventId}`,
this.logger.error( );
`Failed to process AccountFrozen: ${message.eventId}`, } catch (error) {
error, this.logger.error(
); `Failed to process AccountFrozen: ${message.eventId}`,
throw error; error,
} );
} throw error;
}
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND) }
async handleWalletBound(
@Payload() message: DomainEventMessage, @MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
@Ctx() context: KafkaContext, async handleWalletBound(
): Promise<void> { @Payload() message: DomainEventMessage,
this.logger.log(`Received WalletBound event: ${message.eventId}`); @Ctx() context: KafkaContext,
): Promise<void> {
try { this.logger.log(`Received WalletBound event: ${message.eventId}`);
await this.processWalletBound(message.payload);
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`); try {
} catch (error) { await this.processWalletBound(message.payload);
this.logger.error( this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
`Failed to process WalletBound: ${message.eventId}`, } catch (error) {
error, this.logger.error(
); `Failed to process WalletBound: ${message.eventId}`,
throw error; error,
} );
} throw error;
}
// 业务处理方法 }
private async processUserAccountCreated(payload: any): Promise<void> {
this.logger.debug( // 业务处理方法
`Processing UserAccountCreated: userId=${payload.userId}`, private async processUserAccountCreated(payload: any): Promise<void> {
); this.logger.debug(
// 发送欢迎通知 `Processing UserAccountCreated: userId=${payload.userId}`,
// 初始化用户积分 );
// 记录邀请关系 // 发送欢迎通知
} // 初始化用户积分
// 记录邀请关系
private async processDeviceAdded(payload: any): Promise<void> { }
this.logger.debug(
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`, private async processDeviceAdded(payload: any): Promise<void> {
); this.logger.debug(
// 发送新设备登录通知 `Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
// 安全审计记录 );
} // 发送新设备登录通知
// 安全审计记录
private async processPhoneBound(payload: any): Promise<void> { }
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
// 发送绑定成功短信 private async processPhoneBound(payload: any): Promise<void> {
} this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
// 发送绑定成功短信
private async processKYCSubmitted(payload: any): Promise<void> { }
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
// 触发KYC审核流程 private async processKYCSubmitted(payload: any): Promise<void> {
// 通知审核人员 this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
} // 触发KYC审核流程
// 通知审核人员
private async processKYCApproved(payload: any): Promise<void> { }
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
// 发送审核通过通知 private async processKYCApproved(payload: any): Promise<void> {
// 解锁高级功能 this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
} // 发送审核通过通知
// 解锁高级功能
private async processKYCRejected(payload: any): Promise<void> { }
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
// 发送审核失败通知 private async processKYCRejected(payload: any): Promise<void> {
} this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
// 发送审核失败通知
private async processAccountFrozen(payload: any): Promise<void> { }
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
// 发送账户冻结通知 private async processAccountFrozen(payload: any): Promise<void> {
// 清除用户会话 this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
} // 发送账户冻结通知
// 清除用户会话
private async processWalletBound(payload: any): Promise<void> { }
this.logger.debug(
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`, private async processWalletBound(payload: any): Promise<void> {
); this.logger.debug(
// 同步钱包余额 `Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
} );
} // 同步钱包余额
}
}

View File

@ -1,157 +1,176 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import {
import { ConfigService } from '@nestjs/config'; Injectable,
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs'; Logger,
import { DomainEvent } from '@/domain/events'; OnModuleInit,
OnModuleDestroy,
// 定义 Kafka 消息接口 } from '@nestjs/common';
export interface DomainEventMessage { import { ConfigService } from '@nestjs/config';
eventId: string; import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
eventType: string; import { DomainEvent } from '@/domain/events';
occurredAt: string;
aggregateId: string; // 定义 Kafka 消息接口
aggregateType: string; export interface DomainEventMessage {
payload: any; eventId: string;
} eventType: string;
occurredAt: string;
// 定义主题常量 - identity-service 发布的事件 aggregateId: string;
export const IDENTITY_TOPICS = { aggregateType: string;
USER_ACCOUNT_CREATED: 'identity.UserAccountCreated', payload: any;
USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated', }
DEVICE_ADDED: 'identity.DeviceAdded',
DEVICE_REMOVED: 'identity.DeviceRemoved', // 定义主题常量 - identity-service 发布的事件
PHONE_BOUND: 'identity.PhoneBound', export const IDENTITY_TOPICS = {
WALLET_BOUND: 'identity.WalletBound', USER_ACCOUNT_CREATED: 'identity.UserAccountCreated',
MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound', USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated',
KYC_SUBMITTED: 'identity.KYCSubmitted', DEVICE_ADDED: 'identity.DeviceAdded',
KYC_VERIFIED: 'identity.KYCVerified', DEVICE_REMOVED: 'identity.DeviceRemoved',
KYC_REJECTED: 'identity.KYCRejected', PHONE_BOUND: 'identity.PhoneBound',
KYC_APPROVED: 'identity.KYCApproved', WALLET_BOUND: 'identity.WalletBound',
USER_LOCATION_UPDATED: 'identity.UserLocationUpdated', MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound',
USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen', KYC_SUBMITTED: 'identity.KYCSubmitted',
ACCOUNT_FROZEN: 'identity.AccountFrozen', KYC_VERIFIED: 'identity.KYCVerified',
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated', KYC_REJECTED: 'identity.KYCRejected',
// MPC 请求发送到 mpc.* topic让 mpc-service 消费 KYC_APPROVED: 'identity.KYCApproved',
MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested', USER_LOCATION_UPDATED: 'identity.UserLocationUpdated',
MPC_SIGNING_REQUESTED: 'mpc.SigningRequested', USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen',
} as const; ACCOUNT_FROZEN: 'identity.AccountFrozen',
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated',
// 定义 identity-service 需要消费的 MPC 事件主题 // MPC 请求发送到 mpc.* topic让 mpc-service 消费
export const MPC_CONSUME_TOPICS = { MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested',
KEYGEN_COMPLETED: 'mpc.KeygenCompleted', MPC_SIGNING_REQUESTED: 'mpc.SigningRequested',
SESSION_FAILED: 'mpc.SessionFailed', } as const;
} as const;
// 定义 identity-service 需要消费的 MPC 事件主题
@Injectable() export const MPC_CONSUME_TOPICS = {
export class EventPublisherService implements OnModuleInit, OnModuleDestroy { KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
private readonly logger = new Logger(EventPublisherService.name); SESSION_FAILED: 'mpc.SessionFailed',
private kafka: Kafka; } as const;
private producer: Producer;
@Injectable()
constructor(private readonly configService: ConfigService) { export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(','); private readonly logger = new Logger(EventPublisherService.name);
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service'); private kafka: Kafka;
private producer: Producer;
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`); constructor(private readonly configService: ConfigService) {
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`); const brokers = this.configService
.get<string>('KAFKA_BROKERS', 'localhost:9092')
this.kafka = new Kafka({ .split(',');
clientId, const clientId = this.configService.get<string>(
brokers, 'KAFKA_CLIENT_ID',
logLevel: logLevel.WARN, 'identity-service',
}); );
this.producer = this.kafka.producer();
} this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
async onModuleInit() { this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
await this.producer.connect(); this.kafka = new Kafka({
this.logger.log(`[CONNECT] Kafka producer connected successfully`); clientId,
} brokers,
logLevel: logLevel.WARN,
async onModuleDestroy() { });
this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`); this.producer = this.kafka.producer();
await this.producer.disconnect(); }
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
} async onModuleInit() {
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
async publish(event: DomainEvent): Promise<void>; await this.producer.connect();
async publish(topic: string, message: DomainEventMessage): Promise<void>; this.logger.log(`[CONNECT] Kafka producer connected successfully`);
async publish(eventOrTopic: DomainEvent | string, message?: DomainEventMessage): Promise<void> { }
if (typeof eventOrTopic === 'string') {
// 直接发布到指定 topic (用于重试场景) async onModuleDestroy() {
const topic = eventOrTopic; this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`);
const msg = message!; await this.producer.disconnect();
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`); }
this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
async publish(event: DomainEvent): Promise<void>;
await this.producer.send({ async publish(topic: string, message: DomainEventMessage): Promise<void>;
topic, async publish(
messages: [ eventOrTopic: DomainEvent | string,
{ message?: DomainEventMessage,
key: msg.eventId, ): Promise<void> {
value: JSON.stringify(msg), if (typeof eventOrTopic === 'string') {
}, // 直接发布到指定 topic (用于重试场景)
], const topic = eventOrTopic;
}); const msg = message!;
this.logger.log(`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`); this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`);
} else { this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
// 从领域事件发布
const event = eventOrTopic; await this.producer.send({
const topic = this.getTopicForEvent(event); topic,
const payload = (event as any).payload; messages: [
{
this.logger.log(`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`); key: msg.eventId,
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`); value: JSON.stringify(msg),
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`); },
],
const messageValue = { });
eventId: event.eventId,
eventType: event.eventType, this.logger.log(
occurredAt: event.occurredAt.toISOString(), `[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`,
aggregateId: (event as any).aggregateId || '', );
aggregateType: (event as any).aggregateType || 'UserAccount', } else {
payload, // 从领域事件发布
}; const event = eventOrTopic;
const topic = this.getTopicForEvent(event);
await this.producer.send({ const payload = (event as any).payload;
topic,
messages: [ this.logger.log(
{ `[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`,
key: event.eventId, );
value: JSON.stringify(messageValue), this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
}, this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
],
}); const messageValue = {
eventId: event.eventId,
this.logger.log(`[PUBLISH] Successfully published ${event.eventType} to ${topic}`); eventType: event.eventType,
} occurredAt: event.occurredAt.toISOString(),
} aggregateId: (event as any).aggregateId || '',
aggregateType: (event as any).aggregateType || 'UserAccount',
/** payload,
* Kafka topic };
* MPC mpc.* topic identity.* topic
*/ await this.producer.send({
private getTopicForEvent(event: DomainEvent): string { topic,
const eventType = event.eventType; messages: [
{
// MPC 相关事件使用 mpc.* 前缀 key: event.eventId,
if (eventType === 'MpcKeygenRequested') { value: JSON.stringify(messageValue),
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED; },
} ],
if (eventType === 'MpcSigningRequested') { });
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
} this.logger.log(
`[PUBLISH] Successfully published ${event.eventType} to ${topic}`,
// 其他事件使用 identity.* 前缀 );
return `identity.${eventType}`; }
} }
async publishAll(events: DomainEvent[]): Promise<void> { /**
for (const event of events) { * Kafka topic
await this.publish(event); * MPC mpc.* topic identity.* topic
} */
} private getTopicForEvent(event: DomainEvent): string {
} const eventType = event.eventType;
// MPC 相关事件使用 mpc.* 前缀
if (eventType === 'MpcKeygenRequested') {
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED;
}
if (eventType === 'MpcSigningRequested') {
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
}
// 其他事件使用 identity.* 前缀
return `identity.${eventType}`;
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}

View File

@ -1,94 +1,94 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventPublisherService } from './event-publisher.service'; import { EventPublisherService } from './event-publisher.service';
import { DeadLetterService } from './dead-letter.service'; import { DeadLetterService } from './dead-letter.service';
@Injectable() @Injectable()
export class EventRetryService { export class EventRetryService {
private readonly logger = new Logger(EventRetryService.name); private readonly logger = new Logger(EventRetryService.name);
private readonly maxRetries = 3; private readonly maxRetries = 3;
private isRunning = false; private isRunning = false;
constructor( constructor(
private readonly eventPublisher: EventPublisherService, private readonly eventPublisher: EventPublisherService,
private readonly deadLetterService: DeadLetterService, private readonly deadLetterService: DeadLetterService,
) {} ) {}
// 可以通过 API 手动触发或由外部调度器调用 // 可以通过 API 手动触发或由外部调度器调用
async retryFailedEvents(): Promise<void> { async retryFailedEvents(): Promise<void> {
if (this.isRunning) { if (this.isRunning) {
this.logger.debug('Retry job already running, skipping'); this.logger.debug('Retry job already running, skipping');
return; return;
} }
this.isRunning = true; this.isRunning = true;
this.logger.log('Starting failed events retry job'); this.logger.log('Starting failed events retry job');
try { try {
const failedEvents = await this.deadLetterService.getFailedEvents(50); const failedEvents = await this.deadLetterService.getFailedEvents(50);
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
for (const event of failedEvents) { for (const event of failedEvents) {
if (event.retryCount >= this.maxRetries) { if (event.retryCount >= this.maxRetries) {
this.logger.warn( this.logger.warn(
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`, `Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
); );
continue; continue;
} }
try { try {
await this.eventPublisher.publish(event.topic, { await this.eventPublisher.publish(event.topic, {
eventId: event.eventId, eventId: event.eventId,
occurredAt: event.createdAt.toISOString(), occurredAt: event.createdAt.toISOString(),
aggregateId: event.aggregateId, aggregateId: event.aggregateId,
aggregateType: event.aggregateType, aggregateType: event.aggregateType,
eventType: event.eventType, eventType: event.eventType,
payload: event.payload, payload: event.payload,
}); });
await this.deadLetterService.markAsProcessed(event.id); await this.deadLetterService.markAsProcessed(event.id);
successCount++; successCount++;
this.logger.log(`Successfully retried event: ${event.eventId}`); this.logger.log(`Successfully retried event: ${event.eventId}`);
} catch (error) { } catch (error) {
failCount++; failCount++;
await this.deadLetterService.incrementRetryCount(event.id); await this.deadLetterService.incrementRetryCount(event.id);
this.logger.error(`Failed to retry event: ${event.eventId}`, error); this.logger.error(`Failed to retry event: ${event.eventId}`, error);
} }
} }
this.logger.log( this.logger.log(
`Finished retry job: ${successCount} succeeded, ${failCount} failed`, `Finished retry job: ${successCount} succeeded, ${failCount} failed`,
); );
} finally { } finally {
this.isRunning = false; this.isRunning = false;
} }
} }
async manualRetry(eventId: string): Promise<boolean> { async manualRetry(eventId: string): Promise<boolean> {
const events = await this.deadLetterService.getFailedEvents(1000); const events = await this.deadLetterService.getFailedEvents(1000);
const event = events.find((e) => e.eventId === eventId); const event = events.find((e) => e.eventId === eventId);
if (!event) { if (!event) {
this.logger.warn(`Event not found: ${eventId}`); this.logger.warn(`Event not found: ${eventId}`);
return false; return false;
} }
try { try {
await this.eventPublisher.publish(event.topic, { await this.eventPublisher.publish(event.topic, {
eventId: event.eventId, eventId: event.eventId,
occurredAt: event.createdAt.toISOString(), occurredAt: event.createdAt.toISOString(),
aggregateId: event.aggregateId, aggregateId: event.aggregateId,
aggregateType: event.aggregateType, aggregateType: event.aggregateType,
eventType: event.eventType, eventType: event.eventType,
payload: event.payload, payload: event.payload,
}); });
await this.deadLetterService.markAsProcessed(event.id); await this.deadLetterService.markAsProcessed(event.id);
this.logger.log(`Manually retried event: ${eventId}`); this.logger.log(`Manually retried event: ${eventId}`);
return true; return true;
} catch (error) { } catch (error) {
this.logger.error(`Failed to manually retry event: ${eventId}`, error); this.logger.error(`Failed to manually retry event: ${eventId}`, error);
return false; return false;
} }
} }
} }

View File

@ -1,7 +1,7 @@
export * from './kafka.module'; export * from './kafka.module';
export * from './event-publisher.service'; export * from './event-publisher.service';
export * from './event-consumer.controller'; export * from './event-consumer.controller';
export * from './dead-letter.service'; export * from './dead-letter.service';
export * from './event-retry.service'; export * from './event-retry.service';
export * from './mpc-event-consumer.service'; export * from './mpc-event-consumer.service';
export * from './blockchain-event-consumer.service'; export * from './blockchain-event-consumer.service';

View File

@ -1,26 +1,26 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EventPublisherService } from './event-publisher.service'; import { EventPublisherService } from './event-publisher.service';
import { MpcEventConsumerService } from './mpc-event-consumer.service'; import { MpcEventConsumerService } from './mpc-event-consumer.service';
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service'; import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
import { OutboxPublisherService } from './outbox-publisher.service'; import { OutboxPublisherService } from './outbox-publisher.service';
import { OutboxRepository } from '../persistence/repositories/outbox.repository'; import { OutboxRepository } from '../persistence/repositories/outbox.repository';
import { PrismaService } from '../persistence/prisma/prisma.service'; import { PrismaService } from '../persistence/prisma/prisma.service';
@Module({ @Module({
providers: [ providers: [
PrismaService, PrismaService,
EventPublisherService, EventPublisherService,
MpcEventConsumerService, MpcEventConsumerService,
BlockchainEventConsumerService, BlockchainEventConsumerService,
OutboxRepository, OutboxRepository,
OutboxPublisherService, OutboxPublisherService,
], ],
exports: [ exports: [
EventPublisherService, EventPublisherService,
MpcEventConsumerService, MpcEventConsumerService,
BlockchainEventConsumerService, BlockchainEventConsumerService,
OutboxRepository, OutboxRepository,
OutboxPublisherService, OutboxPublisherService,
], ],
}) })
export class KafkaModule {} export class KafkaModule {}

View File

@ -5,7 +5,12 @@
* Updates user wallet addresses when keygen completes. * Updates user wallet addresses when keygen completes.
*/ */
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs'; import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
@ -32,7 +37,7 @@ export interface KeygenCompletedPayload {
threshold: string; threshold: string;
extraPayload?: { extraPayload?: {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
username: string; username: string;
delegateShare?: { delegateShare?: {
partyId: string; partyId: string;
@ -85,15 +90,20 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async onModuleInit() { async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092']; const brokers = this.configService
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service'; .get<string>('KAFKA_BROKERS')
?.split(',') || ['localhost:9092'];
const clientId =
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const groupId = 'identity-service-mpc-events'; const groupId = 'identity-service-mpc-events';
this.logger.log(`[INIT] MPC Event Consumer initializing...`); this.logger.log(`[INIT] MPC Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`); this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`); this.logger.log(`[INIT] GroupId: ${groupId}`);
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`); this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`); this.logger.log(
`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`,
);
// 企业级重试配置:指数退避,最多重试约 2.5 小时 // 企业级重试配置:指数退避,最多重试约 2.5 小时
this.kafka = new Kafka({ this.kafka = new Kafka({
@ -101,10 +111,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
brokers, brokers,
logLevel: logLevel.WARN, logLevel: logLevel.WARN,
retry: { retry: {
initialRetryTime: 1000, // 1 秒 initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟 maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次 retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子 multiplier: 2, // 指数退避因子
restartOnFailure: async () => true, restartOnFailure: async () => true,
}, },
}); });
@ -119,16 +129,26 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`); this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
await this.consumer.connect(); await this.consumer.connect();
this.isConnected = true; this.isConnected = true;
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`); this.logger.log(
`[CONNECT] MPC Event Kafka consumer connected successfully`,
);
// Subscribe to MPC topics // Subscribe to MPC topics
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false }); await this.consumer.subscribe({
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`); topics: Object.values(MPC_TOPICS),
fromBeginning: false,
});
this.logger.log(
`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`,
);
// Start consuming // Start consuming
await this.startConsuming(); await this.startConsuming();
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error); this.logger.error(
`[ERROR] Failed to connect MPC Event Kafka consumer`,
error,
);
} }
} }
@ -169,9 +189,15 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
private async startConsuming(): Promise<void> { private async startConsuming(): Promise<void> {
await this.consumer.run({ await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => { eachMessage: async ({
topic,
partition,
message,
}: EachMessagePayload) => {
const offset = message.offset; const offset = message.offset;
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`); this.logger.log(
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
);
try { try {
const value = message.value?.toString(); const value = message.value?.toString();
@ -180,55 +206,83 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
return; return;
} }
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`); this.logger.log(
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
);
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
const payload = parsed.payload || parsed; const payload = parsed.payload || parsed;
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`); this.logger.log(
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`); `[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`,
);
this.logger.log(
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
);
switch (topic) { switch (topic) {
case MPC_TOPICS.KEYGEN_STARTED: case MPC_TOPICS.KEYGEN_STARTED:
this.logger.log(`[HANDLE] Processing KeygenStarted event`); this.logger.log(`[HANDLE] Processing KeygenStarted event`);
if (this.keygenStartedHandler) { if (this.keygenStartedHandler) {
await this.keygenStartedHandler(payload as KeygenStartedPayload); await this.keygenStartedHandler(
payload as KeygenStartedPayload,
);
this.logger.log(`[HANDLE] KeygenStarted handler completed`); this.logger.log(`[HANDLE] KeygenStarted handler completed`);
} else { } else {
this.logger.warn(`[HANDLE] No handler registered for KeygenStarted`); this.logger.warn(
`[HANDLE] No handler registered for KeygenStarted`,
);
} }
break; break;
case MPC_TOPICS.KEYGEN_COMPLETED: case MPC_TOPICS.KEYGEN_COMPLETED:
this.logger.log(`[HANDLE] Processing KeygenCompleted event`); this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
this.logger.log(`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`); this.logger.log(
`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`,
);
if (this.keygenCompletedHandler) { if (this.keygenCompletedHandler) {
await this.keygenCompletedHandler(payload as KeygenCompletedPayload); await this.keygenCompletedHandler(
payload as KeygenCompletedPayload,
);
this.logger.log(`[HANDLE] KeygenCompleted handler completed`); this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
} else { } else {
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`); this.logger.warn(
`[HANDLE] No handler registered for KeygenCompleted`,
);
} }
break; break;
case MPC_TOPICS.SIGNING_COMPLETED: case MPC_TOPICS.SIGNING_COMPLETED:
this.logger.log(`[HANDLE] Processing SigningCompleted event`); this.logger.log(`[HANDLE] Processing SigningCompleted event`);
if (this.signingCompletedHandler) { if (this.signingCompletedHandler) {
await this.signingCompletedHandler(payload as SigningCompletedPayload); await this.signingCompletedHandler(
payload as SigningCompletedPayload,
);
this.logger.log(`[HANDLE] SigningCompleted handler completed`); this.logger.log(`[HANDLE] SigningCompleted handler completed`);
} else { } else {
this.logger.warn(`[HANDLE] No handler registered for SigningCompleted`); this.logger.warn(
`[HANDLE] No handler registered for SigningCompleted`,
);
} }
break; break;
case MPC_TOPICS.SESSION_FAILED: case MPC_TOPICS.SESSION_FAILED:
this.logger.log(`[HANDLE] Processing SessionFailed event`); this.logger.log(`[HANDLE] Processing SessionFailed event`);
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`); this.logger.log(
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`); `[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`,
);
this.logger.log(
`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`,
);
if (this.sessionFailedHandler) { if (this.sessionFailedHandler) {
await this.sessionFailedHandler(payload as SessionFailedPayload); await this.sessionFailedHandler(
payload as SessionFailedPayload,
);
this.logger.log(`[HANDLE] SessionFailed handler completed`); this.logger.log(`[HANDLE] SessionFailed handler completed`);
} else { } else {
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`); this.logger.warn(
`[HANDLE] No handler registered for SessionFailed`,
);
} }
break; break;
@ -236,7 +290,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`); this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
} }
} catch (error) { } catch (error) {
this.logger.error(`[ERROR] Error processing MPC event from ${topic}`, error); this.logger.error(
`[ERROR] Error processing MPC event from ${topic}`,
error,
);
// Re-throw to trigger Kafka retry mechanism // Re-throw to trigger Kafka retry mechanism
// This ensures messages are not marked as consumed until successfully processed // This ensures messages are not marked as consumed until successfully processed
throw error; throw error;

Some files were not shown because too many files have changed in this diff Show More