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

@ -7,6 +7,11 @@ import { ApplicationModule } from '@/application/application.module';
@Module({ @Module({
imports: [ApplicationModule], imports: [ApplicationModule],
controllers: [UserAccountController, AuthController, ReferralsController, TotpController], controllers: [
UserAccountController,
AuthController,
ReferralsController,
TotpController,
],
}) })
export class ApiModule {} export class ApiModule {}

View File

@ -1,4 +1,10 @@
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common'; import {
Controller,
Post,
Body,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
@ -23,7 +29,9 @@ export class AuthController {
@Post('refresh') @Post('refresh')
@ApiOperation({ summary: 'Token刷新' }) @ApiOperation({ summary: 'Token刷新' })
async refresh(@Body() dto: AutoLoginDto) { async refresh(@Body() dto: AutoLoginDto) {
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId)); return this.userService.autoLogin(
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
);
} }
@Public() @Public()
@ -46,12 +54,17 @@ export class AuthController {
// 检查账户状态 // 检查账户状态
if (admin.status !== 'ACTIVE') { if (admin.status !== 'ACTIVE') {
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`); this.logger.warn(
`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`,
);
throw new UnauthorizedException('账户已被禁用'); throw new UnauthorizedException('账户已被禁用');
} }
// 验证密码 (使用 bcrypt) // 验证密码 (使用 bcrypt)
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash); const isPasswordValid = await bcrypt.compare(
dto.password,
admin.passwordHash,
);
if (!isPasswordValid) { if (!isPasswordValid) {
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`); this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);

View File

@ -1,13 +1,29 @@
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 { UserApplicationService } from '@/application/services/user-application.service';
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
import { import {
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand, ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service';
import {
JwtAuthGuard,
Public,
CurrentUser,
CurrentUserData,
} from '@/shared/guards/jwt-auth.guard';
import {
ValidateReferralCodeQuery,
GetReferralStatsQuery,
GenerateReferralLinkCommand,
} from '@/application/commands'; } from '@/application/commands';
import { import {
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto, GenerateReferralLinkDto,
ReferralLinkResponseDto, ReferralStatsResponseDto, MeResponseDto,
ReferralValidationResponseDto,
ReferralLinkResponseDto,
ReferralStatsResponseDto,
} from '@/api/dto'; } from '@/api/dto';
@ApiTags('Referrals') @ApiTags('Referrals')
@ -21,7 +37,10 @@ export class ReferralsController {
*/ */
@Get('me') @Get('me')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' }) @ApiOperation({
summary: '获取当前登录用户信息',
description: '返回用户基本信息、推荐码和推荐链接',
})
@ApiResponse({ status: 200, type: MeResponseDto }) @ApiResponse({ status: 200, type: MeResponseDto })
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> { async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
return this.userService.getMe(user.userId); return this.userService.getMe(user.userId);
@ -32,11 +51,18 @@ export class ReferralsController {
*/ */
@Public() @Public()
@Get('referrals/validate') @Get('referrals/validate')
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' }) @ApiOperation({
summary: '校验推荐码',
description: '创建账号时校验推荐码是否合法',
})
@ApiQuery({ name: 'code', description: '推荐码', required: true }) @ApiQuery({ name: 'code', description: '推荐码', required: true })
@ApiResponse({ status: 200, type: ReferralValidationResponseDto }) @ApiResponse({ status: 200, type: ReferralValidationResponseDto })
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> { async validateReferralCode(
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code)); @Query('code') code: string,
): Promise<ReferralValidationResponseDto> {
return this.userService.validateReferralCode(
new ValidateReferralCodeQuery(code),
);
} }
/** /**
@ -44,7 +70,10 @@ export class ReferralsController {
*/ */
@Post('referrals/links') @Post('referrals/links')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' }) @ApiOperation({
summary: '生成推荐链接',
description: '为当前登录用户生成短链/渠道链接',
})
@ApiResponse({ status: 201, type: ReferralLinkResponseDto }) @ApiResponse({ status: 201, type: ReferralLinkResponseDto })
async generateReferralLink( async generateReferralLink(
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
@ -60,9 +89,16 @@ export class ReferralsController {
*/ */
@Get('referrals/stats') @Get('referrals/stats')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' }) @ApiOperation({
summary: '查询邀请统计',
description: '查询登录用户的邀请记录和统计数据',
})
@ApiResponse({ status: 200, type: ReferralStatsResponseDto }) @ApiResponse({ status: 200, type: ReferralStatsResponseDto })
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> { async getReferralStats(
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId)); @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,29 +1,82 @@
import { import {
Controller, Post, Get, Put, Body, Param, UseGuards, Headers, Controller,
UseInterceptors, UploadedFile, BadRequestException, Post,
Get,
Put,
Body,
Param,
UseGuards,
Headers,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service'; import { UserApplicationService } from '@/application/services/user-application.service';
import { StorageService } from '@/infrastructure/external/storage/storage.service'; import { StorageService } from '@/infrastructure/external/storage/storage.service';
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
import { import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, JwtAuthGuard,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand, Public,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand, CurrentUser,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery, CurrentUserData,
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand, } from '@/shared/guards/jwt-auth.guard';
import {
AutoCreateAccountCommand,
RecoverByMnemonicCommand,
RecoverByPhoneCommand,
AutoLoginCommand,
RegisterCommand,
LoginCommand,
BindPhoneNumberCommand,
UpdateProfileCommand,
SubmitKYCCommand,
RemoveDeviceCommand,
SendSmsCodeCommand,
GetMyProfileQuery,
GetMyDevicesQuery,
GetUserByReferralCodeQuery,
GetWalletStatusQuery,
MarkMnemonicBackedUpCommand,
VerifySmsCodeCommand,
SetPasswordCommand,
} from '@/application/commands'; } from '@/application/commands';
import { import {
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto, AutoCreateAccountDto,
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto, RecoverByMnemonicDto,
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto, RecoverByPhoneDto,
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto, AutoLoginDto,
GenerateBackupCodesDto, RecoverByBackupCodeDto, SendSmsCodeDto,
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto, RegisterDto,
UserProfileResponseDto, DeviceResponseDto, LoginDto,
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto, BindPhoneDto,
VerifySmsCodeDto, SetPasswordDto, UpdateProfileDto,
BindWalletDto,
SubmitKYCDto,
RemoveDeviceDto,
RevokeMnemonicDto,
FreezeAccountDto,
UnfreezeAccountDto,
RequestKeyRotationDto,
GenerateBackupCodesDto,
RecoverByBackupCodeDto,
AutoCreateAccountResponseDto,
RecoverAccountResponseDto,
LoginResponseDto,
UserProfileResponseDto,
DeviceResponseDto,
WalletStatusReadyResponseDto,
WalletStatusGeneratingResponseDto,
VerifySmsCodeDto,
SetPasswordDto,
LoginWithPasswordDto,
} from '@/api/dto'; } from '@/api/dto';
@ApiTags('User') @ApiTags('User')
@ -42,7 +95,9 @@ export class UserAccountController {
async autoCreate(@Body() dto: AutoCreateAccountDto) { async autoCreate(@Body() dto: AutoCreateAccountDto) {
return this.userService.autoCreateAccount( return this.userService.autoCreateAccount(
new AutoCreateAccountCommand( new AutoCreateAccountCommand(
dto.deviceId, dto.deviceName, dto.inviterReferralCode, dto.deviceId,
dto.deviceName,
dto.inviterReferralCode,
), ),
); );
} }
@ -54,7 +109,10 @@ export class UserAccountController {
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) { async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
return this.userService.recoverByMnemonic( return this.userService.recoverByMnemonic(
new RecoverByMnemonicCommand( new RecoverByMnemonicCommand(
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName, dto.accountSequence,
dto.mnemonic,
dto.newDeviceId,
dto.deviceName,
), ),
); );
} }
@ -66,8 +124,11 @@ export class UserAccountController {
async recoverByPhone(@Body() dto: RecoverByPhoneDto) { async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
return this.userService.recoverByPhone( return this.userService.recoverByPhone(
new RecoverByPhoneCommand( new RecoverByPhoneCommand(
dto.accountSequence, dto.phoneNumber, dto.smsCode, dto.accountSequence,
dto.newDeviceId, dto.deviceName, dto.phoneNumber,
dto.smsCode,
dto.newDeviceId,
dto.deviceName,
), ),
); );
} }
@ -86,17 +147,26 @@ export class UserAccountController {
@Post('send-sms-code') @Post('send-sms-code')
@ApiOperation({ summary: '发送短信验证码' }) @ApiOperation({ summary: '发送短信验证码' })
async sendSmsCode(@Body() dto: SendSmsCodeDto) { async sendSmsCode(@Body() dto: SendSmsCodeDto) {
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type)); await this.userService.sendSmsCode(
new SendSmsCodeCommand(dto.phoneNumber, dto.type),
);
return { message: '验证码已发送' }; return { message: '验证码已发送' };
} }
@Public() @Public()
@Post('verify-sms-code') @Post('verify-sms-code')
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' }) @ApiOperation({
summary: '验证短信验证码',
description: '仅验证验证码是否正确,不进行登录或注册',
})
@ApiResponse({ status: 200, description: '验证成功' }) @ApiResponse({ status: 200, description: '验证成功' })
async verifySmsCode(@Body() dto: VerifySmsCodeDto) { async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
await this.userService.verifySmsCode( await this.userService.verifySmsCode(
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'), new VerifySmsCodeCommand(
dto.phoneNumber,
dto.smsCode,
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
),
); );
return { message: '验证成功' }; return { message: '验证成功' };
} }
@ -108,15 +178,18 @@ export class UserAccountController {
async register(@Body() dto: RegisterDto) { async register(@Body() dto: RegisterDto) {
return this.userService.register( return this.userService.register(
new RegisterCommand( new RegisterCommand(
dto.phoneNumber, dto.smsCode, dto.deviceId, dto.phoneNumber,
dto.deviceName, dto.inviterReferralCode, dto.smsCode,
dto.deviceId,
dto.deviceName,
dto.inviterReferralCode,
), ),
); );
} }
@Public() @Public()
@Post('login') @Post('login')
@ApiOperation({ summary: '用户登录(手机号)' }) @ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
@ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 200, type: LoginResponseDto })
async login(@Body() dto: LoginDto) { async login(@Body() dto: LoginDto) {
return this.userService.login( return this.userService.login(
@ -124,10 +197,28 @@ export class UserAccountController {
); );
} }
@Public()
@Post('login-with-password')
@ApiOperation({
summary: '用户登录(手机号+密码)',
description: '用于账号恢复,使用手机号和密码登录',
})
@ApiResponse({ status: 200, type: LoginResponseDto })
async loginWithPassword(@Body() dto: LoginWithPasswordDto) {
return this.userService.loginWithPassword(
dto.phoneNumber,
dto.password,
dto.deviceId,
);
}
@Post('bind-phone') @Post('bind-phone')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '绑定手机号' }) @ApiOperation({ summary: '绑定手机号' })
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) { async bindPhone(
@CurrentUser() user: CurrentUserData,
@Body() dto: BindPhoneDto,
) {
await this.userService.bindPhoneNumber( await this.userService.bindPhoneNumber(
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode), new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
); );
@ -136,9 +227,15 @@ export class UserAccountController {
@Post('set-password') @Post('set-password')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' }) @ApiOperation({
summary: '设置登录密码',
description: '首次设置或修改登录密码',
})
@ApiResponse({ status: 200, description: '密码设置成功' }) @ApiResponse({ status: 200, description: '密码设置成功' })
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) { async setPassword(
@CurrentUser() user: CurrentUserData,
@Body() dto: SetPasswordDto,
) {
await this.userService.setPassword( await this.userService.setPassword(
new SetPasswordCommand(user.userId, dto.password), new SetPasswordCommand(user.userId, dto.password),
); );
@ -156,7 +253,10 @@ export class UserAccountController {
@Put('update-profile') @Put('update-profile')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '更新用户资料' }) @ApiOperation({ summary: '更新用户资料' })
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) { async updateProfile(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateProfileDto,
) {
await this.userService.updateProfile( await this.userService.updateProfile(
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl), new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
); );
@ -166,11 +266,17 @@ export class UserAccountController {
@Post('submit-kyc') @Post('submit-kyc')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '提交KYC认证' }) @ApiOperation({ summary: '提交KYC认证' })
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) { async submitKYC(
@CurrentUser() user: CurrentUserData,
@Body() dto: SubmitKYCDto,
) {
await this.userService.submitKYC( await this.userService.submitKYC(
new SubmitKYCCommand( new SubmitKYCCommand(
user.userId, dto.realName, dto.idCardNumber, user.userId,
dto.idCardFrontUrl, dto.idCardBackUrl, dto.realName,
dto.idCardNumber,
dto.idCardFrontUrl,
dto.idCardBackUrl,
), ),
); );
return { message: '提交成功' }; return { message: '提交成功' };
@ -181,13 +287,18 @@ export class UserAccountController {
@ApiOperation({ summary: '查看我的设备列表' }) @ApiOperation({ summary: '查看我的设备列表' })
@ApiResponse({ status: 200, type: [DeviceResponseDto] }) @ApiResponse({ status: 200, type: [DeviceResponseDto] })
async getMyDevices(@CurrentUser() user: CurrentUserData) { async getMyDevices(@CurrentUser() user: CurrentUserData) {
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId)); return this.userService.getMyDevices(
new GetMyDevicesQuery(user.userId, user.deviceId),
);
} }
@Post('remove-device') @Post('remove-device')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '移除设备' }) @ApiOperation({ summary: '移除设备' })
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) { async removeDevice(
@CurrentUser() user: CurrentUserData,
@Body() dto: RemoveDeviceDto,
) {
await this.userService.removeDevice( await this.userService.removeDevice(
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId), new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
); );
@ -198,14 +309,24 @@ export class UserAccountController {
@Get('by-referral-code/:code') @Get('by-referral-code/:code')
@ApiOperation({ summary: '根据推荐码查询用户' }) @ApiOperation({ summary: '根据推荐码查询用户' })
async getByReferralCode(@Param('code') code: string) { async getByReferralCode(@Param('code') code: string) {
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code)); return this.userService.getUserByReferralCode(
new GetUserByReferralCodeQuery(code),
);
} }
@Get('wallet') @Get('wallet')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '获取我的钱包状态和地址' }) @ApiOperation({ summary: '获取我的钱包状态和地址' })
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto }) @ApiResponse({
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto }) status: 200,
description: '钱包已就绪',
type: WalletStatusReadyResponseDto,
})
@ApiResponse({
status: 202,
description: '钱包生成中',
type: WalletStatusGeneratingResponseDto,
})
async getWalletStatus(@CurrentUser() user: CurrentUserData) { async getWalletStatus(@CurrentUser() user: CurrentUserData) {
return this.userService.getWalletStatus( return this.userService.getWalletStatus(
new GetWalletStatusQuery(user.accountSequence), new GetWalletStatusQuery(user.accountSequence),
@ -214,7 +335,10 @@ export class UserAccountController {
@Post('wallet/retry') @Post('wallet/retry')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' }) @ApiOperation({
summary: '手动重试钱包生成',
description: '当钱包生成失败或超时时,用户可手动触发重试',
})
@ApiResponse({ status: 200, description: '重试请求已提交' }) @ApiResponse({ status: 200, description: '重试请求已提交' })
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) { async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
await this.userService.retryWalletGeneration(user.userId); await this.userService.retryWalletGeneration(user.userId);
@ -234,25 +358,43 @@ export class UserAccountController {
@Post('mnemonic/revoke') @Post('mnemonic/revoke')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' }) @ApiOperation({
summary: '挂失助记词',
description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复',
})
@ApiResponse({ status: 200, description: '挂失结果' }) @ApiResponse({ status: 200, description: '挂失结果' })
async revokeMnemonic(@CurrentUser() user: CurrentUserData, @Body() dto: RevokeMnemonicDto) { async revokeMnemonic(
@CurrentUser() user: CurrentUserData,
@Body() dto: RevokeMnemonicDto,
) {
return this.userService.revokeMnemonic(user.userId, dto.reason); return this.userService.revokeMnemonic(user.userId, dto.reason);
} }
@Post('freeze') @Post('freeze')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' }) @ApiOperation({
summary: '冻结账户',
description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作',
})
@ApiResponse({ status: 200, description: '冻结结果' }) @ApiResponse({ status: 200, description: '冻结结果' })
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) { async freezeAccount(
@CurrentUser() user: CurrentUserData,
@Body() dto: FreezeAccountDto,
) {
return this.userService.freezeAccount(user.userId, dto.reason); return this.userService.freezeAccount(user.userId, dto.reason);
} }
@Post('unfreeze') @Post('unfreeze')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' }) @ApiOperation({
summary: '解冻账户',
description: '验证身份后解冻账户,支持助记词或手机号验证',
})
@ApiResponse({ status: 200, description: '解冻结果' }) @ApiResponse({ status: 200, description: '解冻结果' })
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) { async unfreezeAccount(
@CurrentUser() user: CurrentUserData,
@Body() dto: UnfreezeAccountDto,
) {
return this.userService.unfreezeAccount({ return this.userService.unfreezeAccount({
userId: user.userId, userId: user.userId,
verifyMethod: dto.verifyMethod, verifyMethod: dto.verifyMethod,
@ -264,9 +406,15 @@ export class UserAccountController {
@Post('key-rotation/request') @Post('key-rotation/request')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' }) @ApiOperation({
summary: '请求密钥轮换',
description: '验证当前助记词后,请求轮换 MPC 密钥对',
})
@ApiResponse({ status: 200, description: '轮换请求结果' }) @ApiResponse({ status: 200, description: '轮换请求结果' })
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) { async requestKeyRotation(
@CurrentUser() user: CurrentUserData,
@Body() dto: RequestKeyRotationDto,
) {
return this.userService.requestKeyRotation({ return this.userService.requestKeyRotation({
userId: user.userId, userId: user.userId,
currentMnemonic: dto.currentMnemonic, currentMnemonic: dto.currentMnemonic,
@ -276,9 +424,15 @@ export class UserAccountController {
@Post('backup-codes/generate') @Post('backup-codes/generate')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' }) @ApiOperation({
summary: '生成恢复码',
description: '验证助记词后生成一组一次性恢复码',
})
@ApiResponse({ status: 200, description: '恢复码列表' }) @ApiResponse({ status: 200, description: '恢复码列表' })
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) { async generateBackupCodes(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateBackupCodesDto,
) {
return this.userService.generateBackupCodes({ return this.userService.generateBackupCodes({
userId: user.userId, userId: user.userId,
mnemonic: dto.mnemonic, mnemonic: dto.mnemonic,
@ -300,7 +454,10 @@ export class UserAccountController {
@Post('sms/send-withdraw-code') @Post('sms/send-withdraw-code')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' }) @ApiOperation({
summary: '发送提取验证短信',
description: '向用户绑定的手机号发送提取验证码',
})
@ApiResponse({ status: 200, description: '发送成功' }) @ApiResponse({ status: 200, description: '发送成功' })
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) { async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
await this.userService.sendWithdrawSmsCode(user.userId); await this.userService.sendWithdrawSmsCode(user.userId);
@ -309,31 +466,46 @@ export class UserAccountController {
@Post('sms/verify-withdraw-code') @Post('sms/verify-withdraw-code')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' }) @ApiOperation({
summary: '验证提取短信验证码',
description: '验证提取操作的短信验证码',
})
@ApiResponse({ status: 200, description: '验证结果' }) @ApiResponse({ status: 200, description: '验证结果' })
async verifyWithdrawSmsCode( async verifyWithdrawSmsCode(
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
@Body() body: { code: string }, @Body() body: { code: string },
) { ) {
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code); const valid = await this.userService.verifyWithdrawSmsCode(
user.userId,
body.code,
);
return { valid }; return { valid };
} }
@Post('verify-password') @Post('verify-password')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' }) @ApiOperation({
summary: '验证登录密码',
description: '验证用户的登录密码,用于敏感操作二次验证',
})
@ApiResponse({ status: 200, description: '验证结果' }) @ApiResponse({ status: 200, description: '验证结果' })
async verifyPassword( async verifyPassword(
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
@Body() body: { password: string }, @Body() body: { password: string },
) { ) {
const valid = await this.userService.verifyPassword(user.userId, body.password); const valid = await this.userService.verifyPassword(
user.userId,
body.password,
);
return { valid }; return { valid };
} }
@Get('users/resolve-address/:accountSequence') @Get('users/resolve-address/:accountSequence')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' }) @ApiOperation({
summary: '解析充值ID到区块链地址',
description: '通过用户的 accountSequence 获取其区块链钱包地址',
})
@ApiResponse({ status: 200, description: '返回区块链地址' }) @ApiResponse({ status: 200, description: '返回区块链地址' })
@ApiResponse({ status: 404, description: '找不到用户' }) @ApiResponse({ status: 404, description: '找不到用户' })
async resolveAccountSequenceToAddress( async resolveAccountSequenceToAddress(
@ -376,7 +548,9 @@ export class UserAccountController {
// 验证文件类型 // 验证文件类型
if (!this.storageService.isValidImageType(file.mimetype)) { if (!this.storageService.isValidImageType(file.mimetype)) {
throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp'); throw new BadRequestException(
'不支持的图片格式,请使用 jpg, png, gif 或 webp',
);
} }
// 验证文件大小 // 验证文件大小

View File

@ -5,7 +5,14 @@ export * from './request';
export * from './response'; export * from './response';
// 其他通用DTOs // 其他通用DTOs
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator'; import {
IsString,
IsOptional,
IsNotEmpty,
Matches,
IsEnum,
IsNumber,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AutoLoginDto { export class AutoLoginDto {
@ -144,7 +151,10 @@ export class RemoveDeviceDto {
// Response DTOs // Response DTOs
export class AutoCreateAccountResponseDto { export class AutoCreateAccountResponseDto {
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
})
userSerialNum: string; userSerialNum: string;
@ApiProperty({ example: 'ABC123', description: '推荐码' }) @ApiProperty({ example: 'ABC123', description: '推荐码' })
@ -167,7 +177,10 @@ export class RecoverAccountResponseDto {
@ApiProperty() @ApiProperty()
userId: string; userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string; accountSequence: string;
@ApiProperty() @ApiProperty()
@ -206,7 +219,10 @@ export class WalletStatusReadyResponseDto {
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' }) @ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
walletAddresses: WalletAddressesDto; walletAddresses: WalletAddressesDto;
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' }) @ApiProperty({
example: 'word1 word2 ... word12',
description: '助记词 (12词)',
})
mnemonic: string; mnemonic: string;
} }
@ -220,7 +236,10 @@ export class LoginResponseDto {
@ApiProperty() @ApiProperty()
userId: string; userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string; accountSequence: string;
@ApiProperty() @ApiProperty()
@ -233,7 +252,9 @@ export class LoginResponseDto {
// ============ Referral DTOs ============ // ============ Referral DTOs ============
export class GenerateReferralLinkDto { export class GenerateReferralLinkDto {
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' }) @ApiPropertyOptional({
description: '渠道标识: wechat, telegram, twitter 等',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
channel?: string; channel?: string;
@ -248,7 +269,10 @@ export class MeResponseDto {
@ApiProperty() @ApiProperty()
userId: string; userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string; accountSequence: string;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
@ -266,7 +290,11 @@ export class MeResponseDto {
@ApiProperty({ description: '完整推荐链接' }) @ApiProperty({ description: '完整推荐链接' })
referralLink: string; referralLink: string;
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true }) @ApiProperty({
example: 'D2512110001',
description: '推荐人序列号',
nullable: true,
})
inviterSequence: string | null; inviterSequence: string | null;
@ApiProperty({ description: '钱包地址列表' }) @ApiProperty({ description: '钱包地址列表' })
@ -291,7 +319,7 @@ export class ReferralValidationResponseDto {
@ApiPropertyOptional({ description: '邀请人信息' }) @ApiPropertyOptional({ description: '邀请人信息' })
inviterInfo?: { inviterInfo?: {
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
}; };
@ -324,7 +352,10 @@ export class ReferralLinkResponseDto {
} }
export class InviteRecordDto { export class InviteRecordDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string; accountSequence: string;
@ApiProperty() @ApiProperty()

View File

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

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

@ -11,3 +11,4 @@ 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

@ -2,12 +2,20 @@ 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({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
@IsString() @IsString()
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' }) @Matches(/^D\d{11}$/, {
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
})
accountSequence: string; accountSequence: string;
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' }) @ApiProperty({
example:
'abandon ability able about above absent absorb abstract absurd abuse access accident',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
mnemonic: string; mnemonic: string;

View File

@ -2,9 +2,14 @@ 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({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
@IsString() @IsString()
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' }) @Matches(/^D\d{11}$/, {
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
})
accountSequence: string; accountSequence: string;
@ApiProperty({ example: '13800138000' }) @ApiProperty({ example: '13800138000' })

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

@ -9,7 +9,10 @@ export class SubmitKycDto {
@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(
/^[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: '身份证号格式错误' },
)
idCardNumber: string; idCardNumber: string;
@ApiProperty() @ApiProperty()

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

@ -20,7 +20,10 @@ export class UserProfileDto {
@ApiProperty() @ApiProperty()
userId: string; userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) @ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string; accountSequence: string;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })

View File

@ -1,4 +1,10 @@
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator'; import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'isChinesePhone', async: false }) @ValidatorConstraint({ name: 'isChinesePhone', async: false })
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface { export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
@ -12,7 +18,7 @@ export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
} }
export function IsChinesePhone(validationOptions?: ValidationOptions) { export function IsChinesePhone(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) { return function (object: object, propertyName: string) {
registerDecorator({ registerDecorator({
target: object.constructor, target: object.constructor,
propertyName: propertyName, propertyName: propertyName,
@ -26,7 +32,9 @@ export function IsChinesePhone(validationOptions?: ValidationOptions) {
@ValidatorConstraint({ name: 'isChineseIdCard', async: false }) @ValidatorConstraint({ name: 'isChineseIdCard', async: false })
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface { export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
validate(idCard: string, args: ValidationArguments): boolean { 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); 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 { defaultMessage(args: ValidationArguments): string {
@ -35,7 +43,7 @@ export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
} }
export function IsChineseIdCard(validationOptions?: ValidationOptions) { export function IsChineseIdCard(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) { return function (object: object, propertyName: string) {
registerDecorator({ registerDecorator({
target: object.constructor, target: object.constructor,
propertyName: propertyName, propertyName: propertyName,

View File

@ -6,7 +6,15 @@ 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,
databaseConfig,
jwtConfig,
redisConfig,
kafkaConfig,
smsConfig,
walletConfig,
} from '@/config';
// Controllers // Controllers
import { UserAccountController } from '@/api/controllers/user-account.controller'; import { UserAccountController } from '@/api/controllers/user-account.controller';
@ -25,7 +33,8 @@ import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
// Domain Services // Domain Services
import { import {
AccountSequenceGeneratorService, UserValidatorService, AccountSequenceGeneratorService,
UserValidatorService,
} from '@/domain/services'; } from '@/domain/services';
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
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';
@ -40,11 +49,17 @@ import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consum
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service'; import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service'; import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service'; import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc'; import {
MpcClientService,
MpcWalletService,
} from '@/infrastructure/external/mpc';
import { StorageService } from '@/infrastructure/external/storage/storage.service'; import { StorageService } from '@/infrastructure/external/storage/storage.service';
// Shared // Shared
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter'; import {
GlobalExceptionFilter,
TransformInterceptor,
} from '@/shared/filters/global-exception.filter';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
// ============ Infrastructure Module ============ // ============ Infrastructure Module ============
@ -122,7 +137,13 @@ export class ApplicationModule {}
// ============ API Module ============ // ============ API Module ============
@Module({ @Module({
imports: [ApplicationModule], imports: [ApplicationModule],
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController], controllers: [
HealthController,
UserAccountController,
ReferralsController,
AuthController,
TotpController,
],
}) })
export class ApiModule {} export class ApiModule {}
@ -131,14 +152,24 @@ export class ApiModule {}
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig], load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
kafkaConfig,
smsConfig,
walletConfig,
],
}), }),
JwtModule.registerAsync({ JwtModule.registerAsync({
global: true, global: true,
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') }, signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
},
}), }),
}), }),
InfrastructureModule, InfrastructureModule,

View File

@ -1,8 +1,14 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services'; import {
AccountSequenceGeneratorService,
UserValidatorService,
} from '@/domain/services';
import { ReferralCode, AccountSequence } from '@/domain/value-objects'; import { ReferralCode, AccountSequence } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service'; import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
@ -23,25 +29,34 @@ export class AutoCreateAccountHandler {
private readonly eventPublisher: EventPublisherService, private readonly eventPublisher: EventPublisherService,
) {} ) {}
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> { async execute(
command: AutoCreateAccountCommand,
): Promise<AutoCreateAccountResult> {
this.logger.log(`Creating account for device: ${command.deviceId}`); this.logger.log(`Creating account for device: ${command.deviceId}`);
// 1. 验证设备ID // 1. 验证设备ID
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId); const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!); command.deviceId,
);
if (!deviceCheck.isValid)
throw new ApplicationError(deviceCheck.errorMessage!);
// 2. 验证邀请码 // 2. 验证邀请码
let inviterSequence: AccountSequence | null = null; let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) { if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode); const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode); const referralValidation =
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!); await this.validatorService.validateReferralCode(referralCode);
const inviter = await this.userRepository.findByReferralCode(referralCode); if (!referralValidation.isValid)
throw new ApplicationError(referralValidation.errorMessage!);
const inviter =
await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence; inviterSequence = inviter!.accountSequence;
} }
// 3. 生成用户序列号 // 3. 生成用户序列号
const accountSequence = await this.sequenceGenerator.generateNextUserSequence(); const accountSequence =
await this.sequenceGenerator.generateNextUserSequence();
// 4. 生成用户名和头像 // 4. 生成用户名和头像
const identity = generateIdentity(accountSequence.value); const identity = generateIdentity(accountSequence.value);
@ -52,7 +67,8 @@ export class AutoCreateAccountHandler {
const parts: string[] = []; const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model); if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform); if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion); if (command.deviceName.osVersion)
parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' '); if (parts.length > 0) deviceNameStr = parts.join(' ');
} }
@ -61,7 +77,7 @@ export class AutoCreateAccountHandler {
accountSequence, accountSequence,
initialDeviceId: command.deviceId, initialDeviceId: command.deviceId,
deviceName: deviceNameStr, deviceName: deviceNameStr,
deviceInfo: command.deviceName, // 100% 保持原样存储 deviceInfo: command.deviceName, // 100% 保持原样存储
inviterSequence, inviterSequence,
nickname: identity.username, nickname: identity.username,
avatarSvg: identity.avatarSvg, avatarSvg: identity.avatarSvg,
@ -81,7 +97,9 @@ export class AutoCreateAccountHandler {
await this.eventPublisher.publishAll(account.domainEvents); await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents(); account.clearDomainEvents();
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`); this.logger.log(
`Account created: sequence=${accountSequence.value}, username=${identity.username}`,
);
return { return {
userSerialNum: account.accountSequence.value, userSerialNum: account.accountSequence.value,

View File

@ -1,6 +1,9 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { UserValidatorService } from '@/domain/services'; import { UserValidatorService } from '@/domain/services';
import { UserId, PhoneNumber } from '@/domain/value-objects'; import { UserId, PhoneNumber } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
@ -18,15 +21,22 @@ export class BindPhoneHandler {
) {} ) {}
async execute(command: BindPhoneCommand): Promise<void> { async execute(command: BindPhoneCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId)); const account = await this.userRepository.findById(
UserId.create(command.userId),
);
if (!account) throw new ApplicationError('用户不存在'); if (!account) throw new ApplicationError('用户不存在');
const phoneNumber = PhoneNumber.create(command.phoneNumber); const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`); const cachedCode = await this.redisService.get(
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); `sms:bind:${phoneNumber.value}`,
);
if (cachedCode !== command.smsCode)
throw new ApplicationError('验证码错误或已过期');
const validation = await this.validatorService.validatePhoneNumber(phoneNumber); const validation =
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!); await this.validatorService.validatePhoneNumber(phoneNumber);
if (!validation.isValid)
throw new ApplicationError(validation.errorMessage!);
account.bindPhoneNumber(phoneNumber); account.bindPhoneNumber(phoneNumber);
await this.userRepository.save(account); await this.userRepository.save(account);

View File

@ -1,10 +1,10 @@
// ============ 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 ============
@ -18,7 +18,7 @@ export class AutoCreateAccountCommand {
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,
@ -27,7 +27,7 @@ export class RecoverByMnemonicCommand {
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,
@ -144,13 +144,13 @@ export class GetReferralStatsQuery {
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 {
@ -184,21 +184,21 @@ export interface WalletStatusResult {
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;
@ -208,14 +208,14 @@ export interface RecoverAccountResult {
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;
@ -223,14 +223,14 @@ export interface RegisterResult {
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;
@ -253,7 +253,7 @@ export interface DeviceDTO {
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;
} }
@ -262,7 +262,7 @@ 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;
}; };
@ -281,29 +281,30 @@ export interface ReferralLinkResult {
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位序号 // 最近邀请记录
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
registeredAt: Date; registeredAt: Date;
level: number; // 1=直接, 2=间接 level: number; // 1=直接, 2=间接
}>; }>;
} }
export interface MeResult { export interface MeResult {
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;
referralLink: string; // 完整推荐链接 referralLink: string; // 完整推荐链接
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号) inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
walletAddresses: Array<{ chainType: string; address: string }>; walletAddresses: Array<{ chainType: string; address: string }>;
kycStatus: string; kycStatus: string;

View File

@ -1,6 +1,6 @@
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,6 +1,9 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { AccountSequence } from '@/domain/value-objects'; import { AccountSequence } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service'; import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
@ -21,34 +24,49 @@ export class RecoverByMnemonicHandler {
private readonly blockchainClient: BlockchainClientService, private readonly blockchainClient: BlockchainClientService,
) {} ) {}
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> { async execute(
command: RecoverByMnemonicCommand,
): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence); const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence); const account =
await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在'); if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
// 调用 blockchain-service 验证助记词blockchain-service 内部查询哈希并验证) // 调用 blockchain-service 验证助记词blockchain-service 内部查询哈希并验证)
this.logger.log(`Verifying mnemonic for account ${command.accountSequence}`); this.logger.log(
`Verifying mnemonic for account ${command.accountSequence}`,
);
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({ const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
accountSequence: command.accountSequence, accountSequence: command.accountSequence,
mnemonic: command.mnemonic, mnemonic: command.mnemonic,
}); });
if (!verifyResult.valid) { if (!verifyResult.valid) {
this.logger.warn(`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`); this.logger.warn(
`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`,
);
throw new ApplicationError(verifyResult.message || '助记词错误'); throw new ApplicationError(verifyResult.message || '助记词错误');
} }
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`); this.logger.log(
`Mnemonic verified successfully for account ${command.accountSequence}`,
);
// 如果头像为空,重新生成一个 // 如果头像为空,重新生成一个
let avatarUrl = account.avatarUrl; let avatarUrl = account.avatarUrl;
this.logger.log(`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`); this.logger.log(
`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`,
);
if (avatarUrl) { if (avatarUrl) {
this.logger.log(`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`); this.logger.log(
`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`,
);
} }
if (!avatarUrl) { if (!avatarUrl) {
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`); this.logger.log(
`Account ${command.accountSequence} has no avatar, generating new one`,
);
avatarUrl = generateRandomAvatarSvg(); avatarUrl = generateRandomAvatarSvg();
account.updateProfile({ avatarUrl }); account.updateProfile({ avatarUrl });
} }
@ -76,8 +94,12 @@ export class RecoverByMnemonicHandler {
refreshToken: tokens.refreshToken, refreshToken: tokens.refreshToken,
}; };
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`); this.logger.log(
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`); `RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
);
this.logger.log(
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
);
return result; return result;
} }

View File

@ -1,6 +1,6 @@
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,

View File

@ -1,6 +1,9 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { AccountSequence, PhoneNumber } from '@/domain/value-objects'; import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service'; import { TokenService } from '@/application/services/token.service';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
@ -23,21 +26,29 @@ export class RecoverByPhoneHandler {
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> { async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence); const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence); const account =
await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在'); if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复'); if (!account.phoneNumber)
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
const phoneNumber = PhoneNumber.create(command.phoneNumber); const phoneNumber = PhoneNumber.create(command.phoneNumber);
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配'); if (!account.phoneNumber.equals(phoneNumber))
throw new ApplicationError('手机号与账户不匹配');
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`); const cachedCode = await this.redisService.get(
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); `sms:recover:${phoneNumber.value}`,
);
if (cachedCode !== command.smsCode)
throw new ApplicationError('验证码错误或已过期');
// 如果头像为空,重新生成一个 // 如果头像为空,重新生成一个
let avatarUrl = account.avatarUrl; let avatarUrl = account.avatarUrl;
if (!avatarUrl) { if (!avatarUrl) {
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`); this.logger.log(
`Account ${command.accountSequence} has no avatar, generating new one`,
);
avatarUrl = generateRandomAvatarSvg(); avatarUrl = generateRandomAvatarSvg();
account.updateProfile({ avatarUrl }); account.updateProfile({ avatarUrl });
} }

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,6 +1,9 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { UserId } from '@/domain/value-objects'; import { UserId } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { DeviceDTO } from '@/application/commands'; import { DeviceDTO } from '@/application/commands';
@ -13,7 +16,9 @@ export class GetMyDevicesHandler {
) {} ) {}
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> { async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
const account = await this.userRepository.findById(UserId.create(query.userId)); const account = await this.userRepository.findById(
UserId.create(query.userId),
);
if (!account) throw new ApplicationError('用户不存在'); if (!account) throw new ApplicationError('用户不存在');
return account.getAllDevices().map((device) => ({ return account.getAllDevices().map((device) => ({

View File

@ -1,6 +1,9 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { UserId } from '@/domain/value-objects'; import { UserId } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { ApplicationError } from '@/shared/exceptions/domain.exception';
@ -14,7 +17,9 @@ export class GetMyProfileHandler {
) {} ) {}
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> { async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
const account = await this.userRepository.findById(UserId.create(query.userId)); const account = await this.userRepository.findById(
UserId.create(query.userId),
);
if (!account) throw new ApplicationError('用户不存在'); if (!account) throw new ApplicationError('用户不存在');
return this.toDTO(account); return this.toDTO(account);
} }
@ -33,7 +38,10 @@ export class GetMyProfileHandler {
})), })),
kycStatus: account.kycStatus, kycStatus: account.kycStatus,
kycInfo: account.kycInfo kycInfo: account.kycInfo
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() } ? {
realName: account.kycInfo.realName,
idCardNumber: account.kycInfo.maskedIdCardNumber(),
}
: null, : null,
status: account.status, status: account.status,
registeredAt: account.registeredAt, registeredAt: account.registeredAt,

View File

@ -7,7 +7,7 @@ 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';
} }
@ -22,17 +22,27 @@ export class TokenService {
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',
'2h',
),
},
); );
const refreshToken = this.jwtService.sign( const refreshToken = this.jwtService.sign(
{ ...payload, type: 'refresh' }, { ...payload, type: 'refresh' },
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') }, {
expiresIn: this.configService.get<string>(
'JWT_REFRESH_EXPIRES_IN',
'30d',
),
},
); );
// Save refresh token hash // Save refresh token hash

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

@ -1,13 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { UserApplicationService } from './user-application.service'; import { UserApplicationService } from './user-application.service';
import { USER_ACCOUNT_REPOSITORY, UserAccountRepository, ReferralLinkData, CreateReferralLinkParams } from '@/domain/repositories/user-account.repository.interface'; import {
import { MPC_KEY_SHARE_REPOSITORY, MpcKeyShareRepository } from '@/domain/repositories/mpc-key-share.repository.interface'; USER_ACCOUNT_REPOSITORY,
UserAccountRepository,
ReferralLinkData,
CreateReferralLinkParams,
} from '@/domain/repositories/user-account.repository.interface';
import {
MPC_KEY_SHARE_REPOSITORY,
MpcKeyShareRepository,
} from '@/domain/repositories/mpc-key-share.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequence, ReferralCode, UserId, AccountStatus, KYCStatus, DeviceInfo } from '@/domain/value-objects'; import {
AccountSequence,
ReferralCode,
UserId,
AccountStatus,
KYCStatus,
DeviceInfo,
} from '@/domain/value-objects';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand } from '@/application/commands'; import {
ValidateReferralCodeQuery,
GetReferralStatsQuery,
GenerateReferralLinkCommand,
} from '@/application/commands';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services'; import {
AccountSequenceGeneratorService,
UserValidatorService,
} from '@/domain/services';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service'; import { SmsService } from '@/infrastructure/external/sms/sms.service';
@ -23,16 +45,18 @@ describe('UserApplicationService - Referral APIs', () => {
let mockUserRepository: jest.Mocked<UserAccountRepository>; let mockUserRepository: jest.Mocked<UserAccountRepository>;
// Helper function to create a test account using UserAccount.reconstruct // Helper function to create a test account using UserAccount.reconstruct
const createMockAccount = (params: { const createMockAccount = (
userId?: string; params: {
accountSequence?: string; userId?: string;
referralCode?: string; accountSequence?: string;
nickname?: string; referralCode?: string;
avatarUrl?: string | null; nickname?: string;
isActive?: boolean; avatarUrl?: string | null;
inviterSequence?: string | null; isActive?: boolean;
registeredAt?: Date; inviterSequence?: string | null;
} = {}): UserAccount => { registeredAt?: Date;
} = {},
): UserAccount => {
const devices = [ const devices = [
new DeviceInfo('device-001', 'Test Device', new Date(), new Date()), new DeviceInfo('device-001', 'Test Device', new Date(), new Date()),
]; ];
@ -49,7 +73,8 @@ describe('UserApplicationService - Referral APIs', () => {
walletAddresses: [], walletAddresses: [],
kycInfo: null, kycInfo: null,
kycStatus: KYCStatus.NOT_VERIFIED, kycStatus: KYCStatus.NOT_VERIFIED,
status: params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN, status:
params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN,
registeredAt: params.registeredAt || new Date(), registeredAt: params.registeredAt || new Date(),
lastLoginAt: null, lastLoginAt: null,
updatedAt: new Date(), updatedAt: new Date(),
@ -86,15 +111,17 @@ describe('UserApplicationService - Referral APIs', () => {
const mockConfigService = { const mockConfigService = {
get: jest.fn((key: string) => { get: jest.fn((key: string) => {
const config: Record<string, any> = { const config: Record<string, any> = {
'APP_BASE_URL': 'https://app.rwadurian.com', APP_BASE_URL: 'https://app.rwadurian.com',
'MPC_MODE': 'local', MPC_MODE: 'local',
}; };
return config[key]; return config[key];
}), }),
}; };
const mockAccountSequenceGeneratorService = { const mockAccountSequenceGeneratorService = {
getNext: jest.fn().mockResolvedValue(AccountSequence.create('D2412190001')), getNext: jest
.fn()
.mockResolvedValue(AccountSequence.create('D2412190001')),
}; };
const mockUserValidatorService = { const mockUserValidatorService = {
@ -108,7 +135,9 @@ describe('UserApplicationService - Referral APIs', () => {
const mockTokenService = { const mockTokenService = {
generateAccessToken: jest.fn().mockReturnValue('mock-access-token'), generateAccessToken: jest.fn().mockReturnValue('mock-access-token'),
generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'), generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'),
generateDeviceRefreshToken: jest.fn().mockReturnValue('mock-device-refresh-token'), generateDeviceRefreshToken: jest
.fn()
.mockReturnValue('mock-device-refresh-token'),
verifyRefreshToken: jest.fn(), verifyRefreshToken: jest.fn(),
}; };
@ -240,14 +269,18 @@ describe('UserApplicationService - Referral APIs', () => {
registeredAt: expect.any(Date), registeredAt: expect.any(Date),
}); });
expect(mockUserRepository.findById).toHaveBeenCalledWith(expect.any(UserId)); expect(mockUserRepository.findById).toHaveBeenCalledWith(
expect.any(UserId),
);
}); });
it('should throw error when user not found', async () => { it('should throw error when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null); mockUserRepository.findById.mockResolvedValue(null);
// Use valid numeric string for userId // Use valid numeric string for userId
await expect(service.getMe('999999999')).rejects.toThrow(ApplicationError); await expect(service.getMe('999999999')).rejects.toThrow(
ApplicationError,
);
await expect(service.getMe('999999999')).rejects.toThrow('用户不存在'); await expect(service.getMe('999999999')).rejects.toThrow('用户不存在');
}); });
}); });
@ -266,7 +299,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter); mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter);
const result = await service.validateReferralCode( const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('INVTE1') new ValidateReferralCodeQuery('INVTE1'),
); );
expect(result).toEqual({ expect(result).toEqual({
@ -284,7 +317,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.findByReferralCode.mockResolvedValue(null); mockUserRepository.findByReferralCode.mockResolvedValue(null);
const result = await service.validateReferralCode( const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('INVLD1') new ValidateReferralCodeQuery('INVLD1'),
); );
expect(result).toEqual({ expect(result).toEqual({
@ -302,7 +335,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter); mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter);
const result = await service.validateReferralCode( const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('FROZN1') new ValidateReferralCodeQuery('FROZN1'),
); );
expect(result).toEqual({ expect(result).toEqual({
@ -313,7 +346,7 @@ describe('UserApplicationService - Referral APIs', () => {
it('should return valid=false for invalid referral code format', async () => { it('should return valid=false for invalid referral code format', async () => {
const result = await service.validateReferralCode( const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('invalid-format-too-long') new ValidateReferralCodeQuery('invalid-format-too-long'),
); );
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
@ -343,13 +376,15 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData); mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink( const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', 'wechat') new GenerateReferralLinkCommand('123456789', 'wechat'),
); );
expect(result).toEqual({ expect(result).toEqual({
linkId: '1', linkId: '1',
referralCode: 'ABC123', referralCode: 'ABC123',
shortUrl: expect.stringMatching(/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/), shortUrl: expect.stringMatching(
/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/,
),
fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat', fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat',
channel: 'wechat', channel: 'wechat',
campaignId: null, campaignId: null,
@ -385,7 +420,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData); mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink( const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024') new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024'),
); );
expect(result.channel).toBe('telegram'); expect(result.channel).toBe('telegram');
@ -412,7 +447,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData); mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink( const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789') new GenerateReferralLinkCommand('123456789'),
); );
expect(result.fullUrl).toContain('ch=default'); expect(result.fullUrl).toContain('ch=default');
@ -424,7 +459,9 @@ describe('UserApplicationService - Referral APIs', () => {
// Use valid numeric string for userId // Use valid numeric string for userId
await expect( await expect(
service.generateReferralLink(new GenerateReferralLinkCommand('999999999')) service.generateReferralLink(
new GenerateReferralLinkCommand('999999999'),
),
).rejects.toThrow(ApplicationError); ).rejects.toThrow(ApplicationError);
}); });
}); });
@ -471,7 +508,7 @@ describe('UserApplicationService - Referral APIs', () => {
.mockResolvedValueOnce([]); // Indirect invites via user 3 (none) .mockResolvedValueOnce([]); // Indirect invites via user 3 (none)
const result = await service.getReferralStats( const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789') new GetReferralStatsQuery('123456789'),
); );
expect(result).toEqual({ expect(result).toEqual({
@ -505,7 +542,7 @@ describe('UserApplicationService - Referral APIs', () => {
mockUserRepository.findByInviterSequence.mockResolvedValue([]); mockUserRepository.findByInviterSequence.mockResolvedValue([]);
const result = await service.getReferralStats( const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789') new GetReferralStatsQuery('123456789'),
); );
expect(result).toEqual({ expect(result).toEqual({
@ -555,7 +592,7 @@ describe('UserApplicationService - Referral APIs', () => {
.mockResolvedValue([]); // No second-level invites .mockResolvedValue([]); // No second-level invites
const result = await service.getReferralStats( const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789') new GetReferralStatsQuery('123456789'),
); );
expect(result.directInvites).toBe(3); expect(result.directInvites).toBe(3);
@ -567,7 +604,7 @@ describe('UserApplicationService - Referral APIs', () => {
// Use valid numeric string for userId // Use valid numeric string for userId
await expect( await expect(
service.getReferralStats(new GetReferralStatsQuery('999999999')) service.getReferralStats(new GetReferralStatsQuery('999999999')),
).rejects.toThrow(ApplicationError); ).rejects.toThrow(ApplicationError);
}); });
@ -599,7 +636,7 @@ describe('UserApplicationService - Referral APIs', () => {
.mockResolvedValue([]); .mockResolvedValue([]);
const result = await service.getReferralStats( const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789') new GetReferralStatsQuery('123456789'),
); );
// Newest should be first // Newest should be first
@ -630,15 +667,18 @@ describe('UserApplicationService - Referral APIs', () => {
createdAt: new Date(), createdAt: new Date(),
}; };
mockUserRepository.createReferralLink.mockResolvedValueOnce(mockLinkData); mockUserRepository.createReferralLink.mockResolvedValueOnce(
mockLinkData,
);
await service.generateReferralLink( await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', `channel${i}`) new GenerateReferralLinkCommand('123456789', `channel${i}`),
); );
} }
// All generated short codes should have 6 characters // All generated short codes should have 6 characters
const createReferralLinkCalls = mockUserRepository.createReferralLink.mock.calls; const createReferralLinkCalls =
mockUserRepository.createReferralLink.mock.calls;
createReferralLinkCalls.forEach((call) => { createReferralLinkCalls.forEach((call) => {
const params = call[0] as CreateReferralLinkParams; const params = call[0] as CreateReferralLinkParams;
expect(params.shortCode).toHaveLength(6); expect(params.shortCode).toHaveLength(6);

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

View File

@ -1,5 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountSequenceGeneratorService, UserValidatorService } from './services'; import {
AccountSequenceGeneratorService,
UserValidatorService,
} from './services';
import { UserAccountFactory } from './aggregates/user-account/user-account.factory'; import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface'; import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl'; import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';

View File

@ -24,21 +24,39 @@ export class WalletAddress {
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 {
return this._address;
}
get publicKey(): string {
return this._publicKey;
}
get addressDigest(): string {
return this._addressDigest;
}
get mpcSignature(): MpcSignature {
return this._mpcSignature;
}
get status(): AddressStatus {
return this._status;
}
get boundAt(): Date {
return this._boundAt;
}
private constructor( private constructor(
addressId: AddressId, addressId: AddressId,
@ -101,7 +119,7 @@ export class WalletAddress {
address: string; address: string;
publicKey: string; publicKey: string;
addressDigest: string; addressDigest: string;
mpcSignature: string; // 64 bytes hex mpcSignature: string; // 64 bytes hex
status: AddressStatus; status: AddressStatus;
boundAt: Date; boundAt: Date;
}): WalletAddress { }): WalletAddress {
@ -153,10 +171,19 @@ export class WalletAddress {
for (const v of [27, 28]) { for (const v of [27, 28]) {
try { try {
const sig = ethers.Signature.from({ r, s, v }); const sig = ethers.Signature.from({ r, s, v });
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true); digestBytes,
sig,
);
const compressedRecovered = ethers.SigningKey.computePublicKey(
recoveredPubKey,
true,
);
if (compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase()) { if (
compressedRecovered.slice(2).toLowerCase() ===
this._publicKey.toLowerCase()
) {
return true; return true;
} }
} catch { } catch {
@ -194,7 +221,7 @@ export class WalletAddress {
userId: UserId; userId: UserId;
chainType: ChainType; chainType: ChainType;
address: string; address: string;
publicKey?: string; // 公钥 publicKey?: string; // 公钥
}): WalletAddress { }): WalletAddress {
if (!this.validateAddress(params.chainType, params.address)) { if (!this.validateAddress(params.chainType, params.address)) {
throw new DomainError(`${params.chainType}地址格式错误`); throw new DomainError(`${params.chainType}地址格式错误`);
@ -206,7 +233,7 @@ export class WalletAddress {
params.address, params.address,
params.publicKey || '', params.publicKey || '',
'', '',
'', // empty signature '', // empty signature
AddressStatus.ACTIVE, AddressStatus.ACTIVE,
new Date(), new Date(),
); );
@ -229,20 +256,27 @@ export class WalletAddress {
address, address,
'', '',
'', '',
'', // empty signature '', // empty signature
AddressStatus.ACTIVE, AddressStatus.ACTIVE,
new Date(), new Date(),
); );
} }
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string { private static deriveAddress(
chainType: ChainType,
mnemonic: Mnemonic,
): string {
const seed = mnemonic.toSeed(); const seed = mnemonic.toSeed();
const config = CHAIN_CONFIG[chainType]; const config = CHAIN_CONFIG[chainType];
switch (chainType) { switch (chainType) {
case ChainType.KAVA: case ChainType.KAVA:
case ChainType.DST: case ChainType.DST:
return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix); return this.deriveCosmosAddress(
Buffer.from(seed),
config.derivationPath,
config.prefix,
);
case ChainType.BSC: case ChainType.BSC:
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath); return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
default: default:
@ -250,7 +284,11 @@ export class WalletAddress {
} }
} }
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string { private static deriveCosmosAddress(
seed: Buffer,
path: string,
prefix: string,
): string {
const hdkey = HDKey.fromMasterSeed(seed); const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path); const childKey = hdkey.derive(path);
if (!childKey.publicKey) throw new DomainError('无法派生公钥'); if (!childKey.publicKey) throw new DomainError('无法派生公钥');
@ -270,7 +308,10 @@ export class WalletAddress {
return wallet.address; return wallet.address;
} }
private static validateAddress(chainType: ChainType, address: string): boolean { private static validateAddress(
chainType: ChainType,
address: string,
): boolean {
switch (chainType) { switch (chainType) {
case ChainType.KAVA: case ChainType.KAVA:
case ChainType.BSC: case ChainType.BSC:

View File

@ -14,10 +14,10 @@ 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;
}, },
) { ) {
@ -33,11 +33,11 @@ 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;
}, },
) { ) {
@ -53,7 +53,7 @@ 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;
}, },
@ -77,7 +77,9 @@ export class DeviceRemovedEvent extends DomainEvent {
} }
export class PhoneNumberBoundEvent extends DomainEvent { export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) { constructor(
public readonly payload: { userId: string; phoneNumber: string },
) {
super(); super();
} }
@ -87,7 +89,13 @@ export class PhoneNumberBoundEvent extends DomainEvent {
} }
export class WalletAddressBoundEvent extends DomainEvent { export class WalletAddressBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; chainType: string; address: string }) { constructor(
public readonly payload: {
userId: string;
chainType: string;
address: string;
},
) {
super(); super();
} }
@ -112,7 +120,13 @@ export class MultipleWalletAddressesBoundEvent extends DomainEvent {
} }
export class KYCSubmittedEvent extends DomainEvent { export class KYCSubmittedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) { constructor(
public readonly payload: {
userId: string;
realName: string;
idCardNumber: string;
},
) {
super(); super();
} }
@ -152,7 +166,9 @@ export class UserAccountFrozenEvent extends DomainEvent {
} }
export class UserAccountDeactivatedEvent extends DomainEvent { export class UserAccountDeactivatedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) { constructor(
public readonly payload: { userId: string; deactivatedAt: Date },
) {
super(); super();
} }
@ -201,7 +217,7 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
public readonly payload: { public readonly payload: {
sessionId: string; sessionId: string;
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
username: string; username: string;
threshold: number; threshold: number;
totalParties: number; totalParties: number;

View File

@ -1,7 +1,9 @@
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(
public readonly payload: { userId: string; phoneNumber: string },
) {
super(); super();
} }

View File

@ -1,7 +1,13 @@
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,
AccountSequence,
PhoneNumber,
ReferralCode,
ChainType,
AccountStatus,
KYCStatus,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
export interface Pagination { export interface Pagination {
@ -35,18 +41,32 @@ export interface UserAccountRepository {
findByDeviceId(deviceId: string): Promise<UserAccount | null>; findByDeviceId(deviceId: string): Promise<UserAccount | null>;
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>; findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>; findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>; findByWalletAddress(
chainType: ChainType,
address: string,
): Promise<UserAccount | null>;
getMaxAccountSequence(): Promise<AccountSequence | null>; getMaxAccountSequence(): Promise<AccountSequence | null>;
getNextAccountSequence(): Promise<AccountSequence>; getNextAccountSequence(): Promise<AccountSequence>;
findUsers( findUsers(
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string }, filters?: {
status?: AccountStatus;
kycStatus?: KYCStatus;
keyword?: string;
},
pagination?: Pagination, pagination?: Pagination,
): Promise<UserAccount[]>; ): Promise<UserAccount[]>;
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>; countUsers(filters?: {
status?: AccountStatus;
kycStatus?: KYCStatus;
}): Promise<number>;
// 推荐相关 // 推荐相关
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>; findByInviterSequence(
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>; inviterSequence: AccountSequence,
): Promise<UserAccount[]>;
createReferralLink(
params: CreateReferralLinkParams,
): Promise<ReferralLinkData>;
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>; findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
} }

View File

@ -1,5 +1,8 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { AccountSequence } from '@/domain/value-objects'; import { AccountSequence } from '@/domain/value-objects';
@Injectable() @Injectable()

View File

@ -1,6 +1,14 @@
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,
} from '@/domain/repositories/user-account.repository.interface';
import {
AccountSequence,
PhoneNumber,
ReferralCode,
ChainType,
} from '@/domain/value-objects';
// ============ ValidationResult ============ // ============ ValidationResult ============
export class ValidationResult { export class ValidationResult {
@ -39,7 +47,9 @@ export class UserValidatorService {
private readonly repository: UserAccountRepository, private readonly repository: UserAccountRepository,
) {} ) {}
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> { async validatePhoneNumber(
phoneNumber: PhoneNumber,
): Promise<ValidationResult> {
const existing = await this.repository.findByPhoneNumber(phoneNumber); const existing = await this.repository.findByPhoneNumber(phoneNumber);
if (existing) return ValidationResult.failure('该手机号已注册'); if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success(); return ValidationResult.success();
@ -53,15 +63,24 @@ export class UserValidatorService {
// return ValidationResult.success(); // return ValidationResult.success();
} }
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> { async validateReferralCode(
referralCode: ReferralCode,
): Promise<ValidationResult> {
const inviter = await this.repository.findByReferralCode(referralCode); const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在'); if (!inviter) return ValidationResult.failure('推荐码不存在');
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); if (!inviter.isActive)
return ValidationResult.failure('推荐人账户已冻结或注销');
return ValidationResult.success(); return ValidationResult.success();
} }
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> { async validateWalletAddress(
const existing = await this.repository.findByWalletAddress(chainType, address); chainType: ChainType,
address: string,
): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(
chainType,
address,
);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定'); if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success(); return ValidationResult.success();
} }

View File

@ -1,5 +1,8 @@
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 {
UserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@/domain/repositories/user-account.repository.interface';
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects'; import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
export class ValidationResult { export class ValidationResult {
@ -24,7 +27,9 @@ export class UserValidatorService {
private readonly repository: UserAccountRepository, private readonly repository: UserAccountRepository,
) {} ) {}
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> { async validatePhoneNumber(
phoneNumber: PhoneNumber,
): Promise<ValidationResult> {
const existing = await this.repository.findByPhoneNumber(phoneNumber); const existing = await this.repository.findByPhoneNumber(phoneNumber);
if (existing) return ValidationResult.failure('该手机号已注册'); if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success(); return ValidationResult.success();
@ -38,15 +43,24 @@ export class UserValidatorService {
// return ValidationResult.success(); // return ValidationResult.success();
} }
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> { async validateReferralCode(
referralCode: ReferralCode,
): Promise<ValidationResult> {
const inviter = await this.repository.findByReferralCode(referralCode); const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在'); if (!inviter) return ValidationResult.failure('推荐码不存在');
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); if (!inviter.isActive)
return ValidationResult.failure('推荐人账户已冻结或注销');
return ValidationResult.success(); return ValidationResult.success();
} }
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> { async validateWalletAddress(
const existing = await this.repository.findByWalletAddress(chainType, address); chainType: ChainType,
address: string,
): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(
chainType,
address,
);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定'); if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success(); return ValidationResult.success();
} }

View File

@ -10,7 +10,9 @@ export class AccountSequence {
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位)`,
);
} }
} }

View File

@ -6,7 +6,7 @@ export class DeviceInfo {
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;
} }

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

@ -10,13 +10,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 {

View File

@ -11,7 +11,7 @@ describe('Mnemonic ValueObject', () => {
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', () => {
@ -33,7 +33,8 @@ describe('Mnemonic ValueObject', () => {
describe('create', () => { describe('create', () => {
it('应该接受有效的助记词字符串', () => { it('应该接受有效的助记词字符串', () => {
const validMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const validMnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const mnemonic = Mnemonic.create(validMnemonic); const mnemonic = Mnemonic.create(validMnemonic);
expect(mnemonic.value).toBe(validMnemonic); expect(mnemonic.value).toBe(validMnemonic);
@ -54,7 +55,8 @@ describe('Mnemonic ValueObject', () => {
}); });
it('应该拒绝非英文单词', () => { it('应该拒绝非英文单词', () => {
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词'; const invalidMnemonic =
'中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
expect(() => { expect(() => {
Mnemonic.create(invalidMnemonic); Mnemonic.create(invalidMnemonic);
@ -74,7 +76,8 @@ describe('Mnemonic ValueObject', () => {
describe('toSeed', () => { describe('toSeed', () => {
it('相同的助记词应该生成相同的 seed', () => { it('相同的助记词应该生成相同的 seed', () => {
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const mnemonicStr =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const mnemonic1 = Mnemonic.create(mnemonicStr); const mnemonic1 = Mnemonic.create(mnemonicStr);
const mnemonic2 = Mnemonic.create(mnemonicStr); const mnemonic2 = Mnemonic.create(mnemonicStr);
@ -97,7 +100,8 @@ describe('Mnemonic ValueObject', () => {
describe('equals', () => { describe('equals', () => {
it('相同的助记词应该相等', () => { it('相同的助记词应该相等', () => {
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const mnemonicStr =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const mnemonic1 = Mnemonic.create(mnemonicStr); const mnemonic1 = Mnemonic.create(mnemonicStr);
const mnemonic2 = Mnemonic.create(mnemonicStr); const mnemonic2 = Mnemonic.create(mnemonicStr);

View File

@ -12,7 +12,7 @@ describe('PhoneNumber ValueObject', () => {
'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);
}); });
@ -20,15 +20,15 @@ describe('PhoneNumber ValueObject', () => {
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);
@ -42,7 +42,7 @@ describe('PhoneNumber ValueObject', () => {
'+8613800138000', '+8613800138000',
]; ];
invalidPhones.forEach(phone => { invalidPhones.forEach((phone) => {
expect(() => { expect(() => {
PhoneNumber.create(phone); PhoneNumber.create(phone);
}).toThrow(DomainError); }).toThrow(DomainError);

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

@ -22,7 +22,10 @@ import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { EventPublisherService, IDENTITY_TOPICS } from '../../kafka/event-publisher.service'; import {
EventPublisherService,
IDENTITY_TOPICS,
} from '../../kafka/event-publisher.service';
import { import {
MpcEventConsumerService, MpcEventConsumerService,
KeygenCompletedPayload, KeygenCompletedPayload,
@ -38,34 +41,34 @@ export const MPC_REQUEST_TOPICS = {
export interface KeygenRequest { export interface KeygenRequest {
sessionId: string; sessionId: string;
username: string; // 用户名 (自动递增ID) username: string; // 用户名 (自动递增ID)
threshold: number; // t in t-of-n (默认 1, 即 2-of-3) threshold: number; // t in t-of-n (默认 1, 即 2-of-3)
totalParties: number; // n in t-of-n (默认 3) totalParties: number; // n in t-of-n (默认 3)
requireDelegate: boolean; // 是否需要 delegate party requireDelegate: boolean; // 是否需要 delegate party
} }
export interface KeygenResult { export interface KeygenResult {
sessionId: string; sessionId: string;
publicKey: string; // 压缩格式公钥 (33 bytes hex) publicKey: string; // 压缩格式公钥 (33 bytes hex)
delegateShare: DelegateShare; // delegate share (用户分片) delegateShare: DelegateShare; // delegate share (用户分片)
serverParties: string[]; // 服务器 party IDs serverParties: string[]; // 服务器 party IDs
} }
export interface DelegateShare { export interface DelegateShare {
partyId: string; partyId: string;
partyIndex: number; partyIndex: number;
encryptedShare: string; // 加密的分片数据 (hex) encryptedShare: string; // 加密的分片数据 (hex)
} }
export interface SigningRequest { export interface SigningRequest {
username: string; username: string;
messageHash: string; // 32 bytes hex messageHash: string; // 32 bytes hex
userShare?: string; // 如果账户有 delegate share需要传入用户分片 userShare?: string; // 如果账户有 delegate share需要传入用户分片
} }
export interface SigningResult { export interface SigningResult {
sessionId: string; sessionId: string;
signature: string; // 64 bytes hex (R + S) signature: string; // 64 bytes hex (R + S)
messageHash: string; messageHash: string;
} }
@ -96,13 +99,19 @@ export interface AsyncSigningResponse {
} }
// 结果回调类型 // 结果回调类型
export type KeygenResultCallback = (result: KeygenResult | null, error?: string) => Promise<void>; export type KeygenResultCallback = (
export type SigningResultCallback = (result: SigningResult | null, error?: string) => Promise<void>; result: KeygenResult | null,
error?: string,
) => Promise<void>;
export type SigningResultCallback = (
result: SigningResult | null,
error?: string,
) => Promise<void>;
@Injectable() @Injectable()
export class MpcClientService implements OnModuleInit { export class MpcClientService implements OnModuleInit {
private readonly logger = new Logger(MpcClientService.name); private readonly logger = new Logger(MpcClientService.name);
private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL
private readonly mpcMode: string; private readonly mpcMode: string;
private readonly useEventDriven: boolean; private readonly useEventDriven: boolean;
private readonly pollIntervalMs = 2000; private readonly pollIntervalMs = 2000;
@ -110,7 +119,8 @@ export class MpcClientService implements OnModuleInit {
// 待处理的 keygen/signing 请求回调 // 待处理的 keygen/signing 请求回调
private pendingKeygenCallbacks: Map<string, KeygenResultCallback> = new Map(); private pendingKeygenCallbacks: Map<string, KeygenResultCallback> = new Map();
private pendingSigningCallbacks: Map<string, SigningResultCallback> = new Map(); private pendingSigningCallbacks: Map<string, SigningResultCallback> =
new Map();
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
@ -119,15 +129,23 @@ export class MpcClientService implements OnModuleInit {
private readonly mpcEventConsumer: MpcEventConsumerService, private readonly mpcEventConsumer: MpcEventConsumerService,
) { ) {
// 连接 mpc-service (NestJS) // 连接 mpc-service (NestJS)
this.mpcServiceUrl = this.configService.get<string>('MPC_SERVICE_URL', 'http://localhost:3001'); this.mpcServiceUrl = this.configService.get<string>(
'MPC_SERVICE_URL',
'http://localhost:3001',
);
this.mpcMode = this.configService.get<string>('MPC_MODE', 'local'); this.mpcMode = this.configService.get<string>('MPC_MODE', 'local');
this.useEventDriven = this.configService.get<string>('MPC_USE_EVENT_DRIVEN', 'true') === 'true'; this.useEventDriven =
this.configService.get<string>('MPC_USE_EVENT_DRIVEN', 'true') === 'true';
} }
async onModuleInit() { async onModuleInit() {
// 注册 MPC 事件处理器 // 注册 MPC 事件处理器
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this)); this.mpcEventConsumer.onKeygenCompleted(
this.mpcEventConsumer.onSigningCompleted(this.handleSigningCompleted.bind(this)); this.handleKeygenCompleted.bind(this),
);
this.mpcEventConsumer.onSigningCompleted(
this.handleSigningCompleted.bind(this),
);
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this)); this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
this.logger.log('MPC event handlers registered'); this.logger.log('MPC event handlers registered');
} }
@ -146,7 +164,9 @@ export class MpcClientService implements OnModuleInit {
): Promise<AsyncKeygenResponse> { ): Promise<AsyncKeygenResponse> {
const sessionId = this.generateSessionId(); const sessionId = this.generateSessionId();
this.logger.log(`Requesting async keygen: userId=${request.userId}, sessionId=${sessionId}`); this.logger.log(
`Requesting async keygen: userId=${request.userId}, sessionId=${sessionId}`,
);
// 如果是本地模式,直接执行并回调 // 如果是本地模式,直接执行并回调
if (this.mpcMode === 'local') { if (this.mpcMode === 'local') {
@ -189,7 +209,9 @@ export class MpcClientService implements OnModuleInit {
): Promise<AsyncSigningResponse> { ): Promise<AsyncSigningResponse> {
const sessionId = this.generateSessionId(); const sessionId = this.generateSessionId();
this.logger.log(`Requesting async signing: userId=${request.userId}, sessionId=${sessionId}`); this.logger.log(
`Requesting async signing: userId=${request.userId}, sessionId=${sessionId}`,
);
// 如果是本地模式,直接执行并回调 // 如果是本地模式,直接执行并回调
if (this.mpcMode === 'local') { if (this.mpcMode === 'local') {
@ -226,7 +248,9 @@ export class MpcClientService implements OnModuleInit {
// 事件处理器 - 处理 MPC 完成事件 // 事件处理器 - 处理 MPC 完成事件
// ========================================================================== // ==========================================================================
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> { private async handleKeygenCompleted(
payload: KeygenCompletedPayload,
): Promise<void> {
const sessionId = payload.sessionId; const sessionId = payload.sessionId;
const callback = this.pendingKeygenCallbacks.get(sessionId); const callback = this.pendingKeygenCallbacks.get(sessionId);
@ -246,14 +270,19 @@ export class MpcClientService implements OnModuleInit {
}; };
await callback(result); await callback(result);
} catch (error) { } catch (error) {
this.logger.error(`Keygen callback error: sessionId=${sessionId}`, error); this.logger.error(
`Keygen callback error: sessionId=${sessionId}`,
error,
);
} finally { } finally {
this.pendingKeygenCallbacks.delete(sessionId); this.pendingKeygenCallbacks.delete(sessionId);
} }
} }
} }
private async handleSigningCompleted(payload: SigningCompletedPayload): Promise<void> { private async handleSigningCompleted(
payload: SigningCompletedPayload,
): Promise<void> {
const sessionId = payload.sessionId; const sessionId = payload.sessionId;
const callback = this.pendingSigningCallbacks.get(sessionId); const callback = this.pendingSigningCallbacks.get(sessionId);
@ -268,18 +297,25 @@ export class MpcClientService implements OnModuleInit {
}; };
await callback(result); await callback(result);
} catch (error) { } catch (error) {
this.logger.error(`Signing callback error: sessionId=${sessionId}`, error); this.logger.error(
`Signing callback error: sessionId=${sessionId}`,
error,
);
} finally { } finally {
this.pendingSigningCallbacks.delete(sessionId); this.pendingSigningCallbacks.delete(sessionId);
} }
} }
} }
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> { private async handleSessionFailed(
payload: SessionFailedPayload,
): Promise<void> {
const sessionId = payload.sessionId; const sessionId = payload.sessionId;
const sessionType = payload.sessionType; const sessionType = payload.sessionType;
this.logger.warn(`Session failed event received: sessionId=${sessionId}, type=${sessionType}`); this.logger.warn(
`Session failed event received: sessionId=${sessionId}, type=${sessionType}`,
);
if (sessionType === 'keygen') { if (sessionType === 'keygen') {
const callback = this.pendingKeygenCallbacks.get(sessionId); const callback = this.pendingKeygenCallbacks.get(sessionId);
@ -318,7 +354,10 @@ export class MpcClientService implements OnModuleInit {
} }
} catch (error) { } catch (error) {
if (callback) { if (callback) {
await callback(null, error instanceof Error ? error.message : 'Unknown error'); await callback(
null,
error instanceof Error ? error.message : 'Unknown error',
);
} }
} }
} }
@ -339,7 +378,10 @@ export class MpcClientService implements OnModuleInit {
} }
} catch (error) { } catch (error) {
if (callback) { if (callback) {
await callback(null, error instanceof Error ? error.message : 'Unknown error'); await callback(
null,
error instanceof Error ? error.message : 'Unknown error',
);
} }
} }
} }
@ -366,7 +408,9 @@ export class MpcClientService implements OnModuleInit {
* 调用路径: identity-service mpc-service mpc-system * 调用路径: identity-service mpc-service mpc-system
*/ */
async executeKeygen(request: KeygenRequest): Promise<KeygenResult> { async executeKeygen(request: KeygenRequest): Promise<KeygenResult> {
this.logger.log(`Starting MPC keygen: username=${request.username}, t=${request.threshold}, n=${request.totalParties}`); this.logger.log(
`Starting MPC keygen: username=${request.username}, t=${request.threshold}, n=${request.totalParties}`,
);
// 开发模式使用本地模拟 // 开发模式使用本地模拟
if (this.mpcMode === 'local') { if (this.mpcMode === 'local') {
@ -403,7 +447,9 @@ export class MpcClientService implements OnModuleInit {
const sessionResult = await this.pollKeygenStatus(sessionId); const sessionResult = await this.pollKeygenStatus(sessionId);
if (sessionResult.status !== 'completed') { if (sessionResult.status !== 'completed') {
throw new Error(`Keygen session failed with status: ${sessionResult.status}`); throw new Error(
`Keygen session failed with status: ${sessionResult.status}`,
);
} }
this.logger.log(`Keygen completed: publicKey=${sessionResult.publicKey}`); this.logger.log(`Keygen completed: publicKey=${sessionResult.publicKey}`);
@ -415,7 +461,10 @@ export class MpcClientService implements OnModuleInit {
serverParties: sessionResult.serverParties, serverParties: sessionResult.serverParties,
}; };
} catch (error) { } catch (error) {
this.logger.error(`MPC keygen failed: username=${request.username}`, error); this.logger.error(
`MPC keygen failed: username=${request.username}`,
error,
);
throw new Error(`MPC keygen failed: ${error.message}`); throw new Error(`MPC keygen failed: ${error.message}`);
} }
} }
@ -442,12 +491,9 @@ export class MpcClientService implements OnModuleInit {
encryptedShare: string; encryptedShare: string;
}; };
serverParties?: string[]; serverParties?: string[];
}>( }>(`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`, {
`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`, timeout: 10000,
{ }),
timeout: 10000,
},
),
); );
const data = response.data; const data = response.data;
@ -457,7 +503,11 @@ export class MpcClientService implements OnModuleInit {
return { return {
status: 'completed', status: 'completed',
publicKey: data.publicKey || '', publicKey: data.publicKey || '',
delegateShare: data.delegateShare || { partyId: '', partyIndex: -1, encryptedShare: '' }, delegateShare: data.delegateShare || {
partyId: '',
partyIndex: -1,
encryptedShare: '',
},
serverParties: data.serverParties || [], serverParties: data.serverParties || [],
}; };
} }
@ -478,7 +528,9 @@ export class MpcClientService implements OnModuleInit {
} }
} }
throw new Error(`Session ${sessionId} timed out after ${this.maxPollAttempts * this.pollIntervalMs}ms`); throw new Error(
`Session ${sessionId} timed out after ${this.maxPollAttempts * this.pollIntervalMs}ms`,
);
} }
/** /**
@ -487,7 +539,9 @@ export class MpcClientService implements OnModuleInit {
* 调用路径: identity-service mpc-service mpc-system * 调用路径: identity-service mpc-service mpc-system
*/ */
async executeSigning(request: SigningRequest): Promise<SigningResult> { async executeSigning(request: SigningRequest): Promise<SigningResult> {
this.logger.log(`Starting MPC signing: username=${request.username}, messageHash=${request.messageHash}`); this.logger.log(
`Starting MPC signing: username=${request.username}, messageHash=${request.messageHash}`,
);
// 开发模式使用本地模拟 // 开发模式使用本地模拟
if (this.mpcMode === 'local') { if (this.mpcMode === 'local') {
@ -523,7 +577,9 @@ export class MpcClientService implements OnModuleInit {
const signResult = await this.pollSigningStatus(sessionId); const signResult = await this.pollSigningStatus(sessionId);
if (signResult.status !== 'completed') { if (signResult.status !== 'completed') {
throw new Error(`Signing session failed with status: ${signResult.status}`); throw new Error(
`Signing session failed with status: ${signResult.status}`,
);
} }
return { return {
@ -532,7 +588,10 @@ export class MpcClientService implements OnModuleInit {
messageHash: request.messageHash, messageHash: request.messageHash,
}; };
} catch (error) { } catch (error) {
this.logger.error(`MPC signing failed: username=${request.username}`, error); this.logger.error(
`MPC signing failed: username=${request.username}`,
error,
);
throw new Error(`MPC signing failed: ${error.message}`); throw new Error(`MPC signing failed: ${error.message}`);
} }
} }
@ -551,12 +610,9 @@ export class MpcClientService implements OnModuleInit {
sessionId: string; sessionId: string;
status: string; status: string;
signature?: string; signature?: string;
}>( }>(`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`, {
`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`, timeout: 10000,
{ }),
timeout: 10000,
},
),
); );
const data = response.data; const data = response.data;
@ -586,14 +642,16 @@ export class MpcClientService implements OnModuleInit {
} }
private sleep(ms: number): Promise<void> { private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** /**
* MPC keygen () * MPC keygen ()
*/ */
async executeLocalKeygen(request: KeygenRequest): Promise<KeygenResult> { async executeLocalKeygen(request: KeygenRequest): Promise<KeygenResult> {
this.logger.log(`Starting LOCAL MPC keygen (test mode): username=${request.username}`); this.logger.log(
`Starting LOCAL MPC keygen (test mode): username=${request.username}`,
);
const { ethers } = await import('ethers'); const { ethers } = await import('ethers');
@ -602,13 +660,19 @@ export class MpcClientService implements OnModuleInit {
const publicKey = wallet.publicKey; const publicKey = wallet.publicKey;
// 压缩公钥 (33 bytes) // 压缩公钥 (33 bytes)
const compressedPubKey = ethers.SigningKey.computePublicKey(publicKey, true); const compressedPubKey = ethers.SigningKey.computePublicKey(
publicKey,
true,
);
// 模拟 delegate share // 模拟 delegate share
const delegateShare: DelegateShare = { const delegateShare: DelegateShare = {
partyId: 'delegate-party', partyId: 'delegate-party',
partyIndex: 2, partyIndex: 2,
encryptedShare: this.encryptShareData(wallet.privateKey, request.username), encryptedShare: this.encryptShareData(
wallet.privateKey,
request.username,
),
}; };
return { return {
@ -623,7 +687,9 @@ export class MpcClientService implements OnModuleInit {
* MPC () * MPC ()
*/ */
async executeLocalSigning(request: SigningRequest): Promise<SigningResult> { async executeLocalSigning(request: SigningRequest): Promise<SigningResult> {
this.logger.log(`Starting LOCAL MPC signing (test mode): username=${request.username}`); this.logger.log(
`Starting LOCAL MPC signing (test mode): username=${request.username}`,
);
const { ethers } = await import('ethers'); const { ethers } = await import('ethers');

View File

@ -13,7 +13,7 @@ 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;
} }
@ -22,15 +22,15 @@ export interface ChainWalletInfo {
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()
@ -53,15 +53,13 @@ export class MpcWalletService {
}, },
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
@ -73,22 +71,30 @@ export class MpcWalletService {
* 4. 使 MPC * 4. 使 MPC
* 5. * 5.
*/ */
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> { async generateMpcWallet(
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`); params: MpcWalletGenerationParams,
): Promise<MpcWalletGenerationResult> {
this.logger.log(
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
);
// Step 1: 生成 MPC 密钥 // Step 1: 生成 MPC 密钥
const keygenResult = await this.mpcClient.executeKeygen({ const keygenResult = await this.mpcClient.executeKeygen({
sessionId: this.mpcClient.generateSessionId(), sessionId: this.mpcClient.generateSessionId(),
username: params.username, username: params.username,
threshold: 1, // t in t-of-n (2-of-3 means t=1) threshold: 1, // t in t-of-n (2-of-3 means t=1)
totalParties: 3, totalParties: 3,
requireDelegate: true, requireDelegate: true,
}); });
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`); this.logger.log(
`MPC keygen completed: publicKey=${keygenResult.publicKey}`,
);
// Step 2: 从公钥派生三条链的地址 // Step 2: 从公钥派生三条链的地址
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey); const walletAddresses = await this.deriveChainAddresses(
keygenResult.publicKey,
);
// Step 3: 计算地址摘要 // Step 3: 计算地址摘要
const addressDigest = this.computeAddressDigest(walletAddresses); const addressDigest = this.computeAddressDigest(walletAddresses);
@ -99,7 +105,9 @@ export class MpcWalletService {
messageHash: addressDigest, messageHash: addressDigest,
}); });
this.logger.log(`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`); this.logger.log(
`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`,
);
// Step 5: 构建钱包信息 // Step 5: 构建钱包信息
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({ const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
@ -140,7 +148,9 @@ export class MpcWalletService {
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex // 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
if (signature.length !== 128) { if (signature.length !== 128) {
this.logger.error(`Invalid signature length: ${signature.length}, expected 128`); this.logger.error(
`Invalid signature length: ${signature.length}, expected 128`,
);
return false; return false;
} }
@ -155,10 +165,19 @@ export class MpcWalletService {
for (const v of [27, 28]) { for (const v of [27, 28]) {
try { try {
const sig = ethers.Signature.from({ r, s, v }); const sig = ethers.Signature.from({ r, s, v });
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true); digestBytes,
sig,
);
const compressedRecovered = ethers.SigningKey.computePublicKey(
recoveredPubKey,
true,
);
if (compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase()) { if (
compressedRecovered.slice(2).toLowerCase() ===
publicKey.toLowerCase()
) {
return true; return true;
} }
} catch { } catch {
@ -179,13 +198,18 @@ export class MpcWalletService {
* - BSC/KAVA: EVM (keccak256) * - BSC/KAVA: EVM (keccak256)
* - DST: Cosmos Bech32 (ripemd160(sha256)) * - DST: Cosmos Bech32 (ripemd160(sha256))
*/ */
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> { private async deriveChainAddresses(
publicKey: string,
): Promise<{ chainType: string; address: string }[]> {
const { ethers } = await import('ethers'); const { ethers } = await import('ethers');
const { bech32 } = await import('bech32'); const { bech32 } = await import('bech32');
// MPC 公钥 (压缩格式33 bytes) // MPC 公钥 (压缩格式33 bytes)
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey; const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex'); const compressedPubKeyBytes = Buffer.from(
pubKeyHex.replace('0x', ''),
'hex',
);
// 解压公钥 (如果是压缩格式) // 解压公钥 (如果是压缩格式)
let uncompressedPubKey: string; let uncompressedPubKey: string;
@ -204,9 +228,14 @@ export class MpcWalletService {
// ===== Cosmos 地址派生 (DST) ===== // ===== Cosmos 地址派生 (DST) =====
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey))) // 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest(); const sha256Hash = createHash('sha256')
.update(compressedPubKeyBytes)
.digest();
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest(); const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash)); const dstAddress = bech32.encode(
this.chainConfigs.DST.prefix,
bech32.toWords(ripemd160Hash),
);
return [ return [
{ chainType: 'BSC', address: evmAddress }, { chainType: 'BSC', address: evmAddress },
@ -220,14 +249,18 @@ export class MpcWalletService {
* *
* digest = SHA256(BSC地址 + KAVA地址 + DST地址) * digest = SHA256(BSC地址 + KAVA地址 + DST地址)
*/ */
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string { private computeAddressDigest(
addresses: { chainType: string; address: string }[],
): string {
// 按链类型排序以确保一致性 // 按链类型排序以确保一致性
const sortedAddresses = [...addresses].sort((a, b) => const sortedAddresses = [...addresses].sort((a, b) =>
a.chainType.localeCompare(b.chainType), a.chainType.localeCompare(b.chainType),
); );
// 拼接地址 // 拼接地址
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join(''); const concatenated = sortedAddresses
.map((a) => a.address.toLowerCase())
.join('');
// 计算 SHA256 摘要 // 计算 SHA256 摘要
return createHash('sha256').update(concatenated).digest('hex'); return createHash('sha256').update(concatenated).digest('hex');
@ -236,7 +269,10 @@ export class MpcWalletService {
/** /**
* *
*/ */
private computeSingleAddressDigest(address: string, chainType: string): string { private computeSingleAddressDigest(
address: string,
chainType: string,
): string {
const message = `${chainType}:${address.toLowerCase()}`; const message = `${chainType}:${address.toLowerCase()}`;
return createHash('sha256').update(message).digest('hex'); return createHash('sha256').update(message).digest('hex');
} }

View File

@ -24,9 +24,14 @@ export class SmsService implements OnModuleInit {
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 ||
this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
this.enabled =
smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
} }
async onModuleInit() { async onModuleInit() {
@ -35,8 +40,13 @@ export class SmsService implements OnModuleInit {
private async initClient(): Promise<void> { private async initClient(): Promise<void> {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID'); const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET'); const accessKeySecret = this.configService.get<string>(
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com'); 'ALIYUN_ACCESS_KEY_SECRET',
);
const endpoint = this.configService.get<string>(
'ALIYUN_SMS_ENDPOINT',
'dysmsapi.aliyuncs.com',
);
if (!accessKeyId || !accessKeySecret) { if (!accessKeyId || !accessKeySecret) {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式'); this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
@ -64,15 +74,22 @@ export class SmsService implements OnModuleInit {
* @param code * @param code
* @returns * @returns
*/ */
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> { async sendVerificationCode(
phoneNumber: string,
code: string,
): Promise<SmsSendResult> {
// 标准化手机号(去除 +86 前缀) // 标准化手机号(去除 +86 前缀)
const normalizedPhone = this.normalizePhoneNumber(phoneNumber); const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`); this.logger.log(
`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
// 开发环境或未启用时,使用模拟模式 // 开发环境或未启用时,使用模拟模式
if (!this.enabled || !this.client) { if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`); this.logger.warn(
`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
return { return {
success: true, success: true,
requestId: 'mock-request-id', requestId: 'mock-request-id',
@ -92,9 +109,12 @@ export class SmsService implements OnModuleInit {
const runtime = new $Util.RuntimeOptions({ const runtime = new $Util.RuntimeOptions({
connectTimeout: 10000, // 连接超时 10 秒 connectTimeout: 10000, // 连接超时 10 秒
readTimeout: 10000, // 读取超时 10 秒 readTimeout: 10000, // 读取超时 10 秒
}); });
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime); const response = await this.client.sendSmsWithOptions(
sendSmsRequest,
runtime,
);
const body = response.body; const body = response.body;
const result: SmsSendResult = { const result: SmsSendResult = {
@ -106,9 +126,13 @@ export class SmsService implements OnModuleInit {
}; };
if (result.success) { if (result.success) {
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`); this.logger.log(
`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
);
} else { } else {
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`); this.logger.error(
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
);
} }
return result; return result;
@ -148,7 +172,9 @@ export class SmsService implements OnModuleInit {
const normalizedPhone = this.normalizePhoneNumber(phoneNumber); const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
if (!this.enabled || !this.client) { if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`); this.logger.warn(
`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
return { return {
success: true, success: true,
requestId: 'mock-request-id', requestId: 'mock-request-id',
@ -167,9 +193,12 @@ export class SmsService implements OnModuleInit {
const runtime = new $Util.RuntimeOptions({ const runtime = new $Util.RuntimeOptions({
connectTimeout: 10000, // 连接超时 10 秒 connectTimeout: 10000, // 连接超时 10 秒
readTimeout: 10000, // 读取超时 10 秒 readTimeout: 10000, // 读取超时 10 秒
}); });
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime); const response = await this.client.sendSmsWithOptions(
sendSmsRequest,
runtime,
);
const body = response.body; const body = response.body;
return { return {
@ -207,19 +236,23 @@ export class SmsService implements OnModuleInit {
} }
try { try {
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({ const querySendDetailsRequest =
phoneNumber: this.normalizePhoneNumber(phoneNumber), new $Dysmsapi20170525.QuerySendDetailsRequest({
bizId, phoneNumber: this.normalizePhoneNumber(phoneNumber),
sendDate, bizId,
pageSize: 10, sendDate,
currentPage: 1, pageSize: 10,
}); currentPage: 1,
});
const runtime = new $Util.RuntimeOptions({ const runtime = new $Util.RuntimeOptions({
connectTimeout: 10000, // 连接超时 10 秒 connectTimeout: 10000, // 连接超时 10 秒
readTimeout: 10000, // 读取超时 10 秒 readTimeout: 10000, // 读取超时 10 秒
}); });
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime); const response = await this.client.querySendDetailsWithOptions(
querySendDetailsRequest,
runtime,
);
return response.body; return response.body;
} catch (error: any) { } catch (error: any) {
@ -251,6 +284,10 @@ export class SmsService implements OnModuleInit {
if (phoneNumber.length < 7) { if (phoneNumber.length < 7) {
return phoneNumber; return phoneNumber;
} }
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4); 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

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

@ -65,7 +65,9 @@ export class DeadLetterService {
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({
where: { processedAt: { not: null } },
}),
this.prisma.deadLetterEvent.groupBy({ this.prisma.deadLetterEvent.groupBy({
by: ['topic'], by: ['topic'],
_count: true, _count: true,

View File

@ -94,7 +94,9 @@ export class EventConsumerController {
try { try {
await this.processKYCSubmitted(message.payload); await this.processKYCSubmitted(message.payload);
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`); this.logger.log(
`Successfully processed KYCSubmitted: ${message.eventId}`,
);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process KYCSubmitted: ${message.eventId}`, `Failed to process KYCSubmitted: ${message.eventId}`,

View File

@ -1,4 +1,9 @@
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, Producer, Consumer, logLevel } from 'kafkajs'; import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
import { DomainEvent } from '@/domain/events'; import { DomainEvent } from '@/domain/events';
@ -48,8 +53,13 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private producer: Producer; private producer: Producer;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(','); const brokers = this.configService
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service'); .get<string>('KAFKA_BROKERS', 'localhost:9092')
.split(',');
const clientId = this.configService.get<string>(
'KAFKA_CLIENT_ID',
'identity-service',
);
this.logger.log(`[INIT] Kafka EventPublisher initializing...`); this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`); this.logger.log(`[INIT] ClientId: ${clientId}`);
@ -77,7 +87,10 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
async publish(event: DomainEvent): Promise<void>; async publish(event: DomainEvent): Promise<void>;
async publish(topic: string, message: DomainEventMessage): Promise<void>; async publish(topic: string, message: DomainEventMessage): Promise<void>;
async publish(eventOrTopic: DomainEvent | string, message?: DomainEventMessage): Promise<void> { async publish(
eventOrTopic: DomainEvent | string,
message?: DomainEventMessage,
): Promise<void> {
if (typeof eventOrTopic === 'string') { if (typeof eventOrTopic === 'string') {
// 直接发布到指定 topic (用于重试场景) // 直接发布到指定 topic (用于重试场景)
const topic = eventOrTopic; const topic = eventOrTopic;
@ -96,14 +109,18 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
], ],
}); });
this.logger.log(`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`); this.logger.log(
`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`,
);
} else { } else {
// 从领域事件发布 // 从领域事件发布
const event = eventOrTopic; const event = eventOrTopic;
const topic = this.getTopicForEvent(event); const topic = this.getTopicForEvent(event);
const payload = (event as any).payload; const payload = (event as any).payload;
this.logger.log(`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`); this.logger.log(
`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`,
);
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`); this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`); this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
@ -126,7 +143,9 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
], ],
}); });
this.logger.log(`[PUBLISH] Successfully published ${event.eventType} to ${topic}`); this.logger.log(
`[PUBLISH] Successfully published ${event.eventType} to ${topic}`,
);
} }
} }

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;

View File

@ -1,7 +1,15 @@
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, Producer, logLevel } from 'kafkajs'; import { Kafka, Producer, logLevel } from 'kafkajs';
import { OutboxRepository, OutboxEvent } from '../persistence/repositories/outbox.repository'; import {
OutboxRepository,
OutboxEvent,
} from '../persistence/repositories/outbox.repository';
/** /**
* Outbox Publisher Service (B方案 - ) * Outbox Publisher Service (B方案 - )
@ -38,14 +46,31 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly outboxRepository: OutboxRepository, private readonly outboxRepository: OutboxRepository,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) { ) {
this.pollIntervalMs = this.configService.get<number>('OUTBOX_POLL_INTERVAL_MS', 1000); this.pollIntervalMs = this.configService.get<number>(
'OUTBOX_POLL_INTERVAL_MS',
1000,
);
this.batchSize = this.configService.get<number>('OUTBOX_BATCH_SIZE', 100); this.batchSize = this.configService.get<number>('OUTBOX_BATCH_SIZE', 100);
this.cleanupIntervalMs = this.configService.get<number>('OUTBOX_CLEANUP_INTERVAL_MS', 3600000); // 1小时 this.cleanupIntervalMs = this.configService.get<number>(
this.confirmationTimeoutMinutes = this.configService.get<number>('OUTBOX_CONFIRMATION_TIMEOUT_MINUTES', 5); 'OUTBOX_CLEANUP_INTERVAL_MS',
this.timeoutCheckIntervalMs = this.configService.get<number>('OUTBOX_TIMEOUT_CHECK_INTERVAL_MS', 60000); // 1分钟 3600000,
); // 1小时
this.confirmationTimeoutMinutes = this.configService.get<number>(
'OUTBOX_CONFIRMATION_TIMEOUT_MINUTES',
5,
);
this.timeoutCheckIntervalMs = this.configService.get<number>(
'OUTBOX_TIMEOUT_CHECK_INTERVAL_MS',
60000,
); // 1分钟
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(','); const brokers = this.configService
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service'); .get<string>('KAFKA_BROKERS', 'localhost:9092')
.split(',');
const clientId = this.configService.get<string>(
'KAFKA_CLIENT_ID',
'identity-service',
);
this.kafka = new Kafka({ this.kafka = new Kafka({
clientId: `${clientId}-outbox`, clientId: `${clientId}-outbox`,
@ -56,8 +81,8 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
this.logger.log( this.logger.log(
`[OUTBOX] OutboxPublisher (B方案) configured: ` + `[OUTBOX] OutboxPublisher (B方案) configured: ` +
`pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` + `pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` +
`confirmationTimeout=${this.confirmationTimeoutMinutes}min`, `confirmationTimeout=${this.confirmationTimeoutMinutes}min`,
); );
} }
@ -70,7 +95,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
this.start(); this.start();
} catch (error) { } catch (error) {
this.logger.error('[OUTBOX] Failed to connect to Kafka:', error); this.logger.error('[OUTBOX] Failed to connect to Kafka:', error);
this.logger.warn('[OUTBOX] OutboxPublisher will not start - events will accumulate in outbox table'); this.logger.warn(
'[OUTBOX] OutboxPublisher will not start - events will accumulate in outbox table',
);
} }
} }
@ -103,7 +130,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
// 启动超时检查任务B方案核心 // 启动超时检查任务B方案核心
this.timeoutCheckInterval = setInterval(() => { this.timeoutCheckInterval = setInterval(() => {
this.checkConfirmationTimeouts().catch((err) => { this.checkConfirmationTimeouts().catch((err) => {
this.logger.error('[OUTBOX] Error checking confirmation timeouts:', err); this.logger.error(
'[OUTBOX] Error checking confirmation timeouts:',
err,
);
}); });
}, this.timeoutCheckIntervalMs); }, this.timeoutCheckIntervalMs);
@ -114,7 +144,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
}); });
}, this.cleanupIntervalMs); }, this.cleanupIntervalMs);
this.logger.log('[OUTBOX] Outbox publisher started (B方案 - 消费方确认模式)'); this.logger.log(
'[OUTBOX] Outbox publisher started (B方案 - 消费方确认模式)',
);
} }
/** /**
@ -153,10 +185,14 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
try { try {
// 1. 获取待发布事件 // 1. 获取待发布事件
const pendingEvents = await this.outboxRepository.findPendingEvents(this.batchSize); const pendingEvents = await this.outboxRepository.findPendingEvents(
this.batchSize,
);
// 2. 获取需要重试的事件 // 2. 获取需要重试的事件
const retryEvents = await this.outboxRepository.findEventsForRetry(Math.floor(this.batchSize / 2)); const retryEvents = await this.outboxRepository.findEventsForRetry(
Math.floor(this.batchSize / 2),
);
const allEvents = [...pendingEvents, ...retryEvents]; const allEvents = [...pendingEvents, ...retryEvents];
@ -164,7 +200,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
return; return;
} }
this.logger.debug(`[OUTBOX] Processing ${allEvents.length} events (${pendingEvents.length} pending, ${retryEvents.length} retry)`); this.logger.debug(
`[OUTBOX] Processing ${allEvents.length} events (${pendingEvents.length} pending, ${retryEvents.length} retry)`,
);
// 3. 逐个发布 // 3. 逐个发布
for (const event of allEvents) { for (const event of allEvents) {
@ -183,7 +221,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
*/ */
private async publishEvent(event: OutboxEvent): Promise<void> { private async publishEvent(event: OutboxEvent): Promise<void> {
try { try {
this.logger.debug(`[OUTBOX] Publishing event ${event.id} to topic ${event.topic}`); this.logger.debug(
`[OUTBOX] Publishing event ${event.id} to topic ${event.topic}`,
);
// 构造 Kafka 消息,包含 outboxId 用于确认 // 构造 Kafka 消息,包含 outboxId 用于确认
const payload = { const payload = {
@ -213,8 +253,11 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
`[OUTBOX] → Event ${event.id} sent to ${event.topic} (awaiting consumer confirmation)`, `[OUTBOX] → Event ${event.id} sent to ${event.topic} (awaiting consumer confirmation)`,
); );
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage =
this.logger.error(`[OUTBOX] ✗ Failed to publish event ${event.id}: ${errorMessage}`); error instanceof Error ? error.message : String(error);
this.logger.error(
`[OUTBOX] ✗ Failed to publish event ${event.id}: ${errorMessage}`,
);
// 标记为失败并安排重试 // 标记为失败并安排重试
await this.outboxRepository.markAsFailed(event.id, errorMessage); await this.outboxRepository.markAsFailed(event.id, errorMessage);
@ -252,7 +295,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
); );
} }
} catch (error) { } catch (error) {
this.logger.error('[OUTBOX] Error checking confirmation timeouts:', error); this.logger.error(
'[OUTBOX] Error checking confirmation timeouts:',
error,
);
} }
} }
@ -260,7 +306,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
* *
*/ */
private async cleanup(): Promise<void> { private async cleanup(): Promise<void> {
const retentionDays = this.configService.get<number>('OUTBOX_RETENTION_DAYS', 7); const retentionDays = this.configService.get<number>(
'OUTBOX_RETENTION_DAYS',
7,
);
await this.outboxRepository.cleanupOldEvents(retentionDays); await this.outboxRepository.cleanupOldEvents(retentionDays);
} }

View File

@ -1,12 +1,12 @@
// Prisma Entity Types - 用于Mapper转换 // Prisma Entity Types - 用于Mapper转换
export interface UserAccountEntity { export interface UserAccountEntity {
userId: bigint; userId: bigint;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
phoneNumber: string | null; phoneNumber: string | null;
passwordHash: string | null; // bcrypt 哈希密码 passwordHash: string | null; // bcrypt 哈希密码
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号 inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
referralCode: string; referralCode: string;
kycStatus: string; kycStatus: string;
realName: string | null; realName: string | null;
@ -27,7 +27,7 @@ export interface UserDeviceEntity {
userId: bigint; userId: bigint;
deviceId: string; deviceId: string;
deviceName: string | null; deviceName: string | null;
deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON
// Hardware Info (冗余字段,便于查询) // Hardware Info (冗余字段,便于查询)
platform: string | null; platform: string | null;
deviceModel: string | null; deviceModel: string | null;

View File

@ -26,11 +26,15 @@ export function toMpcSignatureString(entity: WalletAddressEntity): string {
* *
* 应用: string (64 bytes hex) -> : {r, s, v} * 应用: string (64 bytes hex) -> : {r, s, v}
*/ */
export function fromMpcSignatureString(signature: string): { r: string; s: string; v: number } { export function fromMpcSignatureString(signature: string): {
r: string;
s: string;
v: number;
} {
// 签名格式: R (32 bytes = 64 hex) + S (32 bytes = 64 hex) // 签名格式: R (32 bytes = 64 hex) + S (32 bytes = 64 hex)
return { return {
r: signature.slice(0, 64) || '', r: signature.slice(0, 64) || '',
s: signature.slice(64, 128) || '', s: signature.slice(64, 128) || '',
v: 0, // 默认 v=0实际验证时尝试 27 和 28 v: 0, // 默认 v=0实际验证时尝试 27 和 28
}; };
} }

View File

@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
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 { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects'; import {
DeviceInfo,
KYCInfo,
KYCStatus,
AccountStatus,
ChainType,
AddressStatus,
} from '@/domain/value-objects';
import { UserAccountEntity } from '../entities/user-account.entity'; import { UserAccountEntity } from '../entities/user-account.entity';
import { toMpcSignatureString } from '../entities/wallet-address.entity'; import { toMpcSignatureString } from '../entities/wallet-address.entity';
@ -27,7 +34,7 @@ export class UserAccountMapper {
address: w.address, address: w.address,
publicKey: w.publicKey, publicKey: w.publicKey,
addressDigest: w.addressDigest, addressDigest: w.addressDigest,
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s) mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
status: w.status as AddressStatus, status: w.status as AddressStatus,
boundAt: w.boundAt, boundAt: w.boundAt,
}), }),
@ -45,12 +52,12 @@ export class UserAccountMapper {
return UserAccount.reconstruct({ return UserAccount.reconstruct({
userId: entity.userId.toString(), userId: entity.userId.toString(),
accountSequence: entity.accountSequence, // 现在是字符串类型 accountSequence: entity.accountSequence, // 现在是字符串类型
devices, devices,
phoneNumber: entity.phoneNumber, phoneNumber: entity.phoneNumber,
nickname: entity.nickname, nickname: entity.nickname,
avatarUrl: entity.avatarUrl, avatarUrl: entity.avatarUrl,
inviterSequence: entity.inviterSequence, // 现在是字符串类型 inviterSequence: entity.inviterSequence, // 现在是字符串类型
referralCode: entity.referralCode, referralCode: entity.referralCode,
walletAddresses: wallets, walletAddresses: wallets,
kycInfo, kycInfo,

View File

@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() { async onModuleInit() {
await this.$connect(); await this.$connect();
} }

View File

@ -3,10 +3,10 @@ import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
export enum OutboxStatus { export enum OutboxStatus {
PENDING = 'PENDING', // 待发送 PENDING = 'PENDING', // 待发送
SENT = 'SENT', // 已发送到 Kafka等待消费方确认 SENT = 'SENT', // 已发送到 Kafka等待消费方确认
CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功 CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功
FAILED = 'FAILED', // 发送失败,等待重试 FAILED = 'FAILED', // 发送失败,等待重试
} }
export interface OutboxEventData { export interface OutboxEventData {
@ -44,7 +44,9 @@ export class OutboxRepository {
): Promise<void> { ): Promise<void> {
if (events.length === 0) return; if (events.length === 0) return;
this.logger.debug(`[OUTBOX] Saving ${events.length} events to outbox (in transaction)`); this.logger.debug(
`[OUTBOX] Saving ${events.length} events to outbox (in transaction)`,
);
await tx.outboxEvent.createMany({ await tx.outboxEvent.createMany({
data: events.map((event) => ({ data: events.map((event) => ({
@ -139,7 +141,9 @@ export class OutboxRepository {
}, },
}); });
this.logger.debug(`[OUTBOX] Marked event ${id} as SENT (awaiting consumer confirmation)`); this.logger.debug(
`[OUTBOX] Marked event ${id} as SENT (awaiting consumer confirmation)`,
);
} }
/** /**
@ -164,11 +168,15 @@ export class OutboxRepository {
}); });
if (result.count > 0) { if (result.count > 0) {
this.logger.log(`[OUTBOX] ✓ Event ${eventId} (${eventType || 'all types'}) confirmed by consumer`); this.logger.log(
`[OUTBOX] ✓ Event ${eventId} (${eventType || 'all types'}) confirmed by consumer`,
);
return true; return true;
} }
this.logger.warn(`[OUTBOX] Event ${eventId} (${eventType || 'any'}) not found or not in SENT status`); this.logger.warn(
`[OUTBOX] Event ${eventId} (${eventType || 'any'}) not found or not in SENT status`,
);
return false; return false;
} }
@ -190,7 +198,10 @@ export class OutboxRepository {
* *
* B方案 * B方案
*/ */
async findSentEventsTimedOut(timeoutMinutes: number = 5, limit: number = 50): Promise<OutboxEvent[]> { async findSentEventsTimedOut(
timeoutMinutes: number = 5,
limit: number = 50,
): Promise<OutboxEvent[]> {
const cutoffTime = new Date(); const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - timeoutMinutes); cutoffTime.setMinutes(cutoffTime.getMinutes() - timeoutMinutes);
@ -232,7 +243,9 @@ export class OutboxRepository {
}, },
}); });
this.logger.warn(`[OUTBOX] Event ${id} reset to PENDING for retry (confirmation timeout)`); this.logger.warn(
`[OUTBOX] Event ${id} reset to PENDING for retry (confirmation timeout)`,
);
} }
/** /**
@ -264,9 +277,13 @@ export class OutboxRepository {
}); });
if (isFinalFailure) { if (isFinalFailure) {
this.logger.error(`[OUTBOX] Event ${id} permanently failed after ${newRetryCount} retries: ${error}`); this.logger.error(
`[OUTBOX] Event ${id} permanently failed after ${newRetryCount} retries: ${error}`,
);
} else { } else {
this.logger.warn(`[OUTBOX] Event ${id} failed, retry ${newRetryCount}/${event.maxRetries}, next retry at ${nextRetryAt}`); this.logger.warn(
`[OUTBOX] Event ${id} failed, retry ${newRetryCount}/${event.maxRetries}, next retry at ${nextRetryAt}`,
);
} }
} }
@ -303,9 +320,13 @@ export class OutboxRepository {
failed: number; failed: number;
}> { }> {
const [pending, sent, confirmed, failed] = await Promise.all([ const [pending, sent, confirmed, failed] = await Promise.all([
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.PENDING } }), this.prisma.outboxEvent.count({
where: { status: OutboxStatus.PENDING },
}),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.SENT } }), this.prisma.outboxEvent.count({ where: { status: OutboxStatus.SENT } }),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.CONFIRMED } }), this.prisma.outboxEvent.count({
where: { status: OutboxStatus.CONFIRMED },
}),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }), this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }),
]); ]);

View File

@ -1,15 +1,29 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { import {
UserAccountRepository, Pagination, ReferralLinkData, CreateReferralLinkParams, UserAccountRepository,
Pagination,
ReferralLinkData,
CreateReferralLinkParams,
} from '@/domain/repositories/user-account.repository.interface'; } from '@/domain/repositories/user-account.repository.interface';
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, UserId,
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus, AccountSequence,
PhoneNumber,
ReferralCode,
ChainType,
AccountStatus,
KYCStatus,
DeviceInfo,
KYCInfo,
AddressStatus,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity'; import {
toMpcSignatureString,
fromMpcSignatureString,
} from '../entities/wallet-address.entity';
@Injectable() @Injectable()
export class UserAccountRepositoryImpl implements UserAccountRepository { export class UserAccountRepositoryImpl implements UserAccountRepository {
@ -76,7 +90,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
userId: savedUserId, userId: savedUserId,
deviceId: d.deviceId, deviceId: d.deviceId,
deviceName: d.deviceName, deviceName: d.deviceName,
deviceInfo: d.deviceInfo ? JSON.parse(JSON.stringify(d.deviceInfo)) : null, // 100% 保存完整 JSON deviceInfo: d.deviceInfo
? JSON.parse(JSON.stringify(d.deviceInfo))
: null, // 100% 保存完整 JSON
platform: (info as any).platform || null, platform: (info as any).platform || null,
deviceModel: (info as any).model || null, deviceModel: (info as any).model || null,
osVersion: (info as any).osVersion || null, osVersion: (info as any).osVersion || null,
@ -140,7 +156,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
return data ? this.toDomain(data) : null; return data ? this.toDomain(data) : null;
} }
async findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null> { async findByAccountSequence(
sequence: AccountSequence,
): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({ const data = await this.prisma.userAccount.findUnique({
where: { accountSequence: sequence.value }, where: { accountSequence: sequence.value },
include: { devices: true, walletAddresses: true }, include: { devices: true, walletAddresses: true },
@ -149,12 +167,16 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
} }
async findByDeviceId(deviceId: string): Promise<UserAccount | null> { async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
const device = await this.prisma.userDevice.findFirst({ where: { deviceId } }); const device = await this.prisma.userDevice.findFirst({
where: { deviceId },
});
if (!device) return null; if (!device) return null;
return this.findById(UserId.create(device.userId.toString())); return this.findById(UserId.create(device.userId.toString()));
} }
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> { async findByPhoneNumber(
phoneNumber: PhoneNumber,
): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({ const data = await this.prisma.userAccount.findUnique({
where: { phoneNumber: phoneNumber.value }, where: { phoneNumber: phoneNumber.value },
include: { devices: true, walletAddresses: true }, include: { devices: true, walletAddresses: true },
@ -162,7 +184,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
return data ? this.toDomain(data) : null; return data ? this.toDomain(data) : null;
} }
async findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null> { async findByReferralCode(
referralCode: ReferralCode,
): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({ const data = await this.prisma.userAccount.findUnique({
where: { referralCode: referralCode.value }, where: { referralCode: referralCode.value },
include: { devices: true, walletAddresses: true }, include: { devices: true, walletAddresses: true },
@ -170,7 +194,10 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
return data ? this.toDomain(data) : null; return data ? this.toDomain(data) : null;
} }
async findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null> { async findByWalletAddress(
chainType: ChainType,
address: string,
): Promise<UserAccount | null> {
const wallet = await this.prisma.walletAddress.findUnique({ const wallet = await this.prisma.walletAddress.findUnique({
where: { uk_chain_address: { chainType, address } }, where: { uk_chain_address: { chainType, address } },
}); });
@ -179,8 +206,12 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
} }
async getMaxAccountSequence(): Promise<AccountSequence | null> { async getMaxAccountSequence(): Promise<AccountSequence | null> {
const result = await this.prisma.userAccount.aggregate({ _max: { accountSequence: true } }); const result = await this.prisma.userAccount.aggregate({
return result._max.accountSequence ? AccountSequence.create(result._max.accountSequence) : null; _max: { accountSequence: true },
});
return result._max.accountSequence
? AccountSequence.create(result._max.accountSequence)
: null;
} }
async getNextAccountSequence(): Promise<AccountSequence> { async getNextAccountSequence(): Promise<AccountSequence> {
@ -215,7 +246,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
} }
async findUsers( async findUsers(
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string }, filters?: {
status?: AccountStatus;
kycStatus?: KYCStatus;
keyword?: string;
},
pagination?: Pagination, pagination?: Pagination,
): Promise<UserAccount[]> { ): Promise<UserAccount[]> {
const where: any = {}; const where: any = {};
@ -239,7 +274,10 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
return data.map((d) => this.toDomain(d)); return data.map((d) => this.toDomain(d));
} }
async countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number> { async countUsers(filters?: {
status?: AccountStatus;
kycStatus?: KYCStatus;
}): Promise<number> {
const where: any = {}; const where: any = {};
if (filters?.status) where.status = filters.status; if (filters?.status) where.status = filters.status;
if (filters?.kycStatus) where.kycStatus = filters.kycStatus; if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
@ -254,7 +292,7 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
d.deviceName || '未命名设备', d.deviceName || '未命名设备',
d.addedAt, d.addedAt,
d.lastActiveAt, d.lastActiveAt,
d.deviceInfo || undefined, // 100% 保持原样 d.deviceInfo || undefined, // 100% 保持原样
); );
}); });
@ -266,7 +304,7 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
address: w.address, address: w.address,
publicKey: w.publicKey || '', publicKey: w.publicKey || '',
addressDigest: w.addressDigest || '', addressDigest: w.addressDigest || '',
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s) mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
status: w.status as AddressStatus, status: w.status as AddressStatus,
boundAt: w.boundAt, boundAt: w.boundAt,
}), }),
@ -303,7 +341,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
// ============ 推荐相关 ============ // ============ 推荐相关 ============
async findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]> { async findByInviterSequence(
inviterSequence: AccountSequence,
): Promise<UserAccount[]> {
const data = await this.prisma.userAccount.findMany({ const data = await this.prisma.userAccount.findMany({
where: { inviterSequence: inviterSequence.value }, where: { inviterSequence: inviterSequence.value },
include: { devices: true, walletAddresses: true }, include: { devices: true, walletAddresses: true },
@ -312,7 +352,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
return data.map((d) => this.toDomain(d)); return data.map((d) => this.toDomain(d));
} }
async createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData> { async createReferralLink(
params: CreateReferralLinkParams,
): Promise<ReferralLinkData> {
const result = await this.prisma.referralLink.create({ const result = await this.prisma.referralLink.create({
data: { data: {
userId: params.userId, userId: params.userId,

View File

@ -39,7 +39,9 @@ async function bootstrap() {
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
// Kafka 微服务 - 用于 @MessagePattern 消费消息 // Kafka 微服务 - 用于 @MessagePattern 消费消息
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092']; const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || [
'localhost:9092',
];
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'identity-service-group'; const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'identity-service-group';
app.connectMicroservice<MicroserviceOptions>({ app.connectMicroservice<MicroserviceOptions>({

View File

@ -2,7 +2,10 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { CurrentUserData } from '@/shared/guards/jwt-auth.guard'; import { CurrentUserData } from '@/shared/guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator( export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => { (
data: keyof CurrentUserData | undefined,
ctx: ExecutionContext,
): CurrentUserData | string | number => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData; const user = request.user as CurrentUserData;
return data ? user?.[data] : user; return data ? user?.[data] : user;

View File

@ -1,4 +1,8 @@
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common'; import {
createParamDecorator,
ExecutionContext,
SetMetadata,
} from '@nestjs/common';
import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard'; import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard';
export interface CurrentUserPayload { export interface CurrentUserPayload {

View File

@ -1,4 +1,9 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';

View File

@ -1,11 +1,22 @@
import { import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, ExceptionFilter,
Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception'; import {
DomainError,
ApplicationError,
} from '@/shared/exceptions/domain.exception';
@Catch() @Catch()
export class GlobalExceptionFilter implements ExceptionFilter { export class GlobalExceptionFilter implements ExceptionFilter {
@ -72,8 +83,14 @@ export interface ApiResponse<T> {
} }
@Injectable() @Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { export class TransformInterceptor<T> implements NestInterceptor<
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> { T,
ApiResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data) => ({ map((data) => ({
success: true, success: true,

View File

@ -1,18 +1,24 @@
import { Injectable, CanActivate, ExecutionContext, createParamDecorator, SetMetadata } from '@nestjs/common'; import {
Injectable,
CanActivate,
ExecutionContext,
createParamDecorator,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { UnauthorizedException } from '@/shared/exceptions/domain.exception'; import { UnauthorizedException } from '@/shared/exceptions/domain.exception';
export interface JwtPayload { export interface JwtPayload {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
type: 'access' | 'refresh'; type: 'access' | 'refresh';
} }
export interface CurrentUserData { export interface CurrentUserData {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
} }
@ -20,7 +26,10 @@ export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const CurrentUser = createParamDecorator( export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => { (
data: keyof CurrentUserData | undefined,
ctx: ExecutionContext,
): CurrentUserData | string | number => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData; const user = request.user as CurrentUserData;
return data ? user?.[data] : user; return data ? user?.[data] : user;
@ -48,7 +57,8 @@ export class JwtAuthGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token); const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
if (payload.type !== 'access') throw new UnauthorizedException('无效的令牌类型'); if (payload.type !== 'access')
throw new UnauthorizedException('无效的令牌类型');
request.user = { request.user = {
userId: payload.userId, userId: payload.userId,
accountSequence: payload.accountSequence, accountSequence: payload.accountSequence,

View File

@ -1,4 +1,9 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -9,8 +14,14 @@ export interface ApiResponse<T> {
} }
@Injectable() @Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { export class TransformInterceptor<T> implements NestInterceptor<
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> { T,
ApiResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data) => ({ map((data) => ({
success: true, success: true,

View File

@ -5,7 +5,7 @@ import { ConfigService } from '@nestjs/config';
export interface JwtPayload { export interface JwtPayload {
userId: string; userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号 accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string; deviceId: string;
type: 'access' | 'refresh'; type: 'access' | 'refresh';
iat: number; iat: number;

View File

@ -118,15 +118,18 @@ const DECORATIONS = [
*/ */
export function generateRandomAvatarSvg(): string { export function generateRandomAvatarSvg(): string {
// 随机选择配色 // 随机选择配色
const palette = COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)]; const palette =
COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)];
// 随机选择榴莲形状 // 随机选择榴莲形状
const shape = DURIAN_SHAPES[Math.floor(Math.random() * DURIAN_SHAPES.length)]; const shape = DURIAN_SHAPES[Math.floor(Math.random() * DURIAN_SHAPES.length)];
// 随机选择表情 // 随机选择表情
const face = FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)]; const face =
FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)];
// 随机选择装饰 (50%概率有装饰) // 随机选择装饰 (50%概率有装饰)
const decoration = Math.random() > 0.5 const decoration =
? DECORATIONS[Math.floor(Math.random() * (DECORATIONS.length - 1))] Math.random() > 0.5
: DECORATIONS[DECORATIONS.length - 1]; ? DECORATIONS[Math.floor(Math.random() * (DECORATIONS.length - 1))]
: DECORATIONS[DECORATIONS.length - 1];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="${palette.bg}"/> <rect width="100" height="100" fill="${palette.bg}"/>
@ -140,7 +143,10 @@ export function generateRandomAvatarSvg(): string {
* *
* @param accountSequence (格式: D + YYMMDD + 5) * @param accountSequence (格式: D + YYMMDD + 5)
*/ */
export function generateIdentity(accountSequence: string): { username: string; avatarSvg: string } { export function generateIdentity(accountSequence: string): {
username: string;
avatarSvg: string;
} {
return { return {
username: generateUsername(accountSequence), username: generateUsername(accountSequence),
avatarSvg: generateRandomAvatarSvg(), avatarSvg: generateRandomAvatarSvg(),

View File

@ -126,9 +126,15 @@ describe('Identity Service E2E Tests', () => {
expect(response.body.data.walletAddresses).toHaveProperty('kava'); expect(response.body.data.walletAddresses).toHaveProperty('kava');
expect(response.body.data.walletAddresses).toHaveProperty('dst'); expect(response.body.data.walletAddresses).toHaveProperty('dst');
expect(response.body.data.walletAddresses).toHaveProperty('bsc'); expect(response.body.data.walletAddresses).toHaveProperty('bsc');
expect(response.body.data.walletAddresses.kava).toMatch(/^kava1[a-z0-9]{38}$/); expect(response.body.data.walletAddresses.kava).toMatch(
expect(response.body.data.walletAddresses.dst).toMatch(/^dst1[a-z0-9]{38}$/); /^kava1[a-z0-9]{38}$/,
expect(response.body.data.walletAddresses.bsc).toMatch(/^0x[a-fA-F0-9]{40}$/); );
expect(response.body.data.walletAddresses.dst).toMatch(
/^dst1[a-z0-9]{38}$/,
);
expect(response.body.data.walletAddresses.bsc).toMatch(
/^0x[a-fA-F0-9]{40}$/,
);
}); });
it('应该验证请求参数', async () => { it('应该验证请求参数', async () => {
@ -182,7 +188,9 @@ describe('Identity Service E2E Tests', () => {
.expect(200); .expect(200);
expect(profileResponse.body.data.nickname).toBe('测试用户'); expect(profileResponse.body.data.nickname).toBe('测试用户');
expect(profileResponse.body.data.avatarUrl).toBe('https://example.com/avatar.jpg'); expect(profileResponse.body.data.avatarUrl).toBe(
'https://example.com/avatar.jpg',
);
expect(profileResponse.body.data.address).toBe('测试地址'); expect(profileResponse.body.data.address).toBe('测试地址');
}); });
}); });
@ -283,7 +291,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status !== 201) { if (response.status !== 201) {
console.log(`Device limit test failed at iteration ${i}:`, { console.log(`Device limit test failed at iteration ${i}:`, {
status: response.status, status: response.status,
body: response.body body: response.body,
}); });
} }
@ -328,8 +336,11 @@ describe('Identity Service E2E Tests', () => {
console.log('Auto-login failed:', { console.log('Auto-login failed:', {
status: response.status, status: response.status,
body: response.body, body: response.body,
sentData: { refreshToken: refreshToken?.substring(0, 20) + '...', deviceId: validDeviceId }, sentData: {
availableDevices: devicesResponse.body.data refreshToken: refreshToken?.substring(0, 20) + '...',
deviceId: validDeviceId,
},
availableDevices: devicesResponse.body.data,
}); });
} }
@ -360,7 +371,7 @@ describe('Identity Service E2E Tests', () => {
console.log('Invalid token test failed:', { console.log('Invalid token test failed:', {
expectedStatus: 401, expectedStatus: 401,
actualStatus: response.status, actualStatus: response.status,
body: response.body body: response.body,
}); });
} }
@ -463,7 +474,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status !== 201) { if (response.status !== 201) {
console.log('Mnemonic recovery failed:', { console.log('Mnemonic recovery failed:', {
status: response.status, status: response.status,
body: response.body body: response.body,
}); });
} }
@ -479,7 +490,8 @@ describe('Identity Service E2E Tests', () => {
.post('/api/v1/user/recover-by-mnemonic') .post('/api/v1/user/recover-by-mnemonic')
.send({ .send({
accountSequence, accountSequence,
mnemonic: 'wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong', mnemonic:
'wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong',
newDeviceId: `wrong-device-${Date.now()}`, newDeviceId: `wrong-device-${Date.now()}`,
deviceName: '错误设备', deviceName: '错误设备',
}) })
@ -501,7 +513,7 @@ describe('Identity Service E2E Tests', () => {
console.log('Mismatch account sequence test failed:', { console.log('Mismatch account sequence test failed:', {
expectedStatus: 404, expectedStatus: 404,
actualStatus: response.status, actualStatus: response.status,
body: response.body body: response.body,
}); });
} }
@ -545,7 +557,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status === 400) { if (response.status === 400) {
console.log(`Phone format test failed for ${phone}:`, { console.log(`Phone format test failed for ${phone}:`, {
status: response.status, status: response.status,
body: response.body body: response.body,
}); });
} }

View File

@ -103,8 +103,12 @@ describe('AutoCreateAccount (e2e)', () => {
]); ]);
// 两个账号应该有不同的序列号和钱包地址 // 两个账号应该有不同的序列号和钱包地址
expect(response1.body.accountSequence).not.toBe(response2.body.accountSequence); expect(response1.body.accountSequence).not.toBe(
expect(response1.body.walletAddresses.bsc).not.toBe(response2.body.walletAddresses.bsc); response2.body.accountSequence,
);
expect(response1.body.walletAddresses.bsc).not.toBe(
response2.body.walletAddresses.bsc,
);
expect(response1.body.publicKey).not.toBe(response2.body.publicKey); expect(response1.body.publicKey).not.toBe(response2.body.publicKey);
}); });

View File

@ -522,6 +522,55 @@ class AccountService {
} }
} }
/// + (POST /user/login-with-password)
///
///
Future<PhoneAuthResponse> loginWithPassword({
required String phoneNumber,
required String password,
required String deviceId,
}) async {
debugPrint('$_tag loginWithPassword() - 开始登录');
debugPrint('$_tag loginWithPassword() - 手机号: $phoneNumber');
try {
debugPrint('$_tag loginWithPassword() - 调用 POST /user/login-with-password');
final response = await _apiClient.post(
'/user/login-with-password',
data: {
'phoneNumber': phoneNumber,
'password': password,
'deviceId': deviceId,
},
);
debugPrint('$_tag loginWithPassword() - API 响应状态码: ${response.statusCode}');
if (response.data == null) {
debugPrint('$_tag loginWithPassword() - 错误: API 返回空响应');
throw const ApiException('登录失败: 空响应');
}
debugPrint('$_tag loginWithPassword() - 解析响应数据');
final responseData = response.data as Map<String, dynamic>;
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
final result = PhoneAuthResponse.fromJson(data);
debugPrint('$_tag loginWithPassword() - 登录成功: ${result.accountSequence}');
//
await _savePhoneAuthData(result, deviceId, phoneNumber);
return result;
} on ApiException catch (e) {
debugPrint('$_tag loginWithPassword() - API 异常: $e');
rethrow;
} catch (e, stackTrace) {
debugPrint('$_tag loginWithPassword() - 未知异常: $e');
debugPrint('$_tag loginWithPassword() - 堆栈: $stackTrace');
throw ApiException('登录失败: $e');
}
}
/// (POST /user/wallet/retry) /// (POST /user/wallet/retry)
/// ///
/// ///

View File

@ -96,7 +96,7 @@ class _GuidePageState extends ConsumerState<GuidePage> {
GuidePageData( GuidePageData(
imagePath: 'assets/images/guide_5.jpg', imagePath: 'assets/images/guide_5.jpg',
title: '欢迎加入', title: '欢迎加入',
subtitle: '创建账号前的最后一步 · 请选择是否有推荐人', subtitle: '注册账号前的最后一步 · 请选择是否有推荐人',
), ),
]; ];
@ -516,7 +516,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
SizedBox(height: 12.h), SizedBox(height: 12.h),
// //
Text( Text(
'创建账号前的最后一步 · 请输入推荐码', '注册账号前的最后一步 · 请输入推荐码',
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
height: 1.43, height: 1.43,
@ -549,7 +549,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
borderRadius: BorderRadius.circular(12.r), borderRadius: BorderRadius.circular(12.r),
), ),
child: Text( child: Text(
'下一步 (创建账号)', '下一步 (注册账号)',
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 16.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@ -4,9 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart'; import '../../../../core/di/injection_container.dart';
import '../../../../core/services/account_service.dart';
import '../../../../core/storage/secure_storage.dart';
import '../../../../core/storage/storage_keys.dart';
import '../../../../routes/route_paths.dart'; import '../../../../routes/route_paths.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
@ -91,26 +88,29 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone'); debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone');
// API AccountService // AccountService
// TODO: + API final accountService = ref.read(accountServiceProvider);
// final response = await accountService.loginWithPassword(phone, password);
// // ID
throw Exception('手机号+密码登录 API 尚未实现'); final deviceId = await accountService.getDeviceId();
debugPrint('[PhoneLoginPage] 获取设备ID成功');
// // API
// 1. access token refresh token final response = await accountService.loginWithPassword(
// 2. userId, accountSequence, referralCode phoneNumber: phone,
// 3. password: password,
// 4. deviceId: deviceId,
);
// if (mounted) { debugPrint('[PhoneLoginPage] 登录成功 - accountSequence: ${response.accountSequence}');
// //
// await ref.read(authProvider.notifier).checkAuthStatus(); if (mounted) {
// //
// // await ref.read(authProvider.notifier).checkAuthStatus();
// context.go(RoutePaths.ranking);
// } //
context.go(RoutePaths.ranking);
}
} catch (e) { } catch (e) {
debugPrint('[PhoneLoginPage] 登录失败: $e'); debugPrint('[PhoneLoginPage] 登录失败: $e');
setState(() { setState(() {