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({
imports: [ApplicationModule],
controllers: [UserAccountController, AuthController, ReferralsController, TotpController],
controllers: [
UserAccountController,
AuthController,
ReferralsController,
TotpController,
],
})
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 { JwtService } from '@nestjs/jwt';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
@ -23,7 +29,9 @@ export class AuthController {
@Post('refresh')
@ApiOperation({ summary: 'Token刷新' })
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()
@ -46,12 +54,17 @@ export class AuthController {
// 检查账户状态
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('账户已被禁用');
}
// 验证密码 (使用 bcrypt)
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
const isPasswordValid = await bcrypt.compare(
dto.password,
admin.passwordHash,
);
if (!isPasswordValid) {
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);

View File

@ -1,13 +1,29 @@
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 {
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';
import {
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
ReferralLinkResponseDto, ReferralStatsResponseDto,
GenerateReferralLinkDto,
MeResponseDto,
ReferralValidationResponseDto,
ReferralLinkResponseDto,
ReferralStatsResponseDto,
} from '@/api/dto';
@ApiTags('Referrals')
@ -21,7 +37,10 @@ export class ReferralsController {
*/
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
@ApiOperation({
summary: '获取当前登录用户信息',
description: '返回用户基本信息、推荐码和推荐链接',
})
@ApiResponse({ status: 200, type: MeResponseDto })
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
return this.userService.getMe(user.userId);
@ -32,11 +51,18 @@ export class ReferralsController {
*/
@Public()
@Get('referrals/validate')
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
@ApiOperation({
summary: '校验推荐码',
description: '创建账号时校验推荐码是否合法',
})
@ApiQuery({ name: 'code', description: '推荐码', required: true })
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
async validateReferralCode(
@Query('code') code: string,
): Promise<ReferralValidationResponseDto> {
return this.userService.validateReferralCode(
new ValidateReferralCodeQuery(code),
);
}
/**
@ -44,7 +70,10 @@ export class ReferralsController {
*/
@Post('referrals/links')
@ApiBearerAuth()
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
@ApiOperation({
summary: '生成推荐链接',
description: '为当前登录用户生成短链/渠道链接',
})
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
async generateReferralLink(
@CurrentUser() user: CurrentUserData,
@ -60,9 +89,16 @@ export class ReferralsController {
*/
@Get('referrals/stats')
@ApiBearerAuth()
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
@ApiOperation({
summary: '查询邀请统计',
description: '查询登录用户的邀请记录和统计数据',
})
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
async getReferralStats(
@CurrentUser() user: CurrentUserData,
): Promise<ReferralStatsResponseDto> {
return this.userService.getReferralStats(
new GetReferralStatsQuery(user.userId),
);
}
}

View File

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

View File

@ -1,29 +1,82 @@
import {
Controller, Post, Get, Put, Body, Param, UseGuards, Headers,
UseInterceptors, UploadedFile, BadRequestException,
Controller,
Post,
Get,
Put,
Body,
Param,
UseGuards,
Headers,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service';
import { StorageService } from '@/infrastructure/external/storage/storage.service';
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand,
JwtAuthGuard,
Public,
CurrentUser,
CurrentUserData,
} 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';
import {
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto,
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto,
GenerateBackupCodesDto, RecoverByBackupCodeDto,
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
UserProfileResponseDto, DeviceResponseDto,
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
VerifySmsCodeDto, SetPasswordDto,
AutoCreateAccountDto,
RecoverByMnemonicDto,
RecoverByPhoneDto,
AutoLoginDto,
SendSmsCodeDto,
RegisterDto,
LoginDto,
BindPhoneDto,
UpdateProfileDto,
BindWalletDto,
SubmitKYCDto,
RemoveDeviceDto,
RevokeMnemonicDto,
FreezeAccountDto,
UnfreezeAccountDto,
RequestKeyRotationDto,
GenerateBackupCodesDto,
RecoverByBackupCodeDto,
AutoCreateAccountResponseDto,
RecoverAccountResponseDto,
LoginResponseDto,
UserProfileResponseDto,
DeviceResponseDto,
WalletStatusReadyResponseDto,
WalletStatusGeneratingResponseDto,
VerifySmsCodeDto,
SetPasswordDto,
LoginWithPasswordDto,
} from '@/api/dto';
@ApiTags('User')
@ -42,7 +95,9 @@ export class UserAccountController {
async autoCreate(@Body() dto: AutoCreateAccountDto) {
return this.userService.autoCreateAccount(
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) {
return this.userService.recoverByMnemonic(
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) {
return this.userService.recoverByPhone(
new RecoverByPhoneCommand(
dto.accountSequence, dto.phoneNumber, dto.smsCode,
dto.newDeviceId, dto.deviceName,
dto.accountSequence,
dto.phoneNumber,
dto.smsCode,
dto.newDeviceId,
dto.deviceName,
),
);
}
@ -86,17 +147,26 @@ export class UserAccountController {
@Post('send-sms-code')
@ApiOperation({ summary: '发送短信验证码' })
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: '验证码已发送' };
}
@Public()
@Post('verify-sms-code')
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' })
@ApiOperation({
summary: '验证短信验证码',
description: '仅验证验证码是否正确,不进行登录或注册',
})
@ApiResponse({ status: 200, description: '验证成功' })
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
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: '验证成功' };
}
@ -108,15 +178,18 @@ export class UserAccountController {
async register(@Body() dto: RegisterDto) {
return this.userService.register(
new RegisterCommand(
dto.phoneNumber, dto.smsCode, dto.deviceId,
dto.deviceName, dto.inviterReferralCode,
dto.phoneNumber,
dto.smsCode,
dto.deviceId,
dto.deviceName,
dto.inviterReferralCode,
),
);
}
@Public()
@Post('login')
@ApiOperation({ summary: '用户登录(手机号)' })
@ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
@ApiResponse({ status: 200, type: LoginResponseDto })
async login(@Body() dto: LoginDto) {
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')
@ApiBearerAuth()
@ApiOperation({ summary: '绑定手机号' })
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
async bindPhone(
@CurrentUser() user: CurrentUserData,
@Body() dto: BindPhoneDto,
) {
await this.userService.bindPhoneNumber(
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
);
@ -136,9 +227,15 @@ export class UserAccountController {
@Post('set-password')
@ApiBearerAuth()
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' })
@ApiOperation({
summary: '设置登录密码',
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(
new SetPasswordCommand(user.userId, dto.password),
);
@ -156,7 +253,10 @@ export class UserAccountController {
@Put('update-profile')
@ApiBearerAuth()
@ApiOperation({ summary: '更新用户资料' })
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
async updateProfile(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateProfileDto,
) {
await this.userService.updateProfile(
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
);
@ -166,11 +266,17 @@ export class UserAccountController {
@Post('submit-kyc')
@ApiBearerAuth()
@ApiOperation({ summary: '提交KYC认证' })
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
async submitKYC(
@CurrentUser() user: CurrentUserData,
@Body() dto: SubmitKYCDto,
) {
await this.userService.submitKYC(
new SubmitKYCCommand(
user.userId, dto.realName, dto.idCardNumber,
dto.idCardFrontUrl, dto.idCardBackUrl,
user.userId,
dto.realName,
dto.idCardNumber,
dto.idCardFrontUrl,
dto.idCardBackUrl,
),
);
return { message: '提交成功' };
@ -181,13 +287,18 @@ export class UserAccountController {
@ApiOperation({ summary: '查看我的设备列表' })
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
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')
@ApiBearerAuth()
@ApiOperation({ summary: '移除设备' })
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
async removeDevice(
@CurrentUser() user: CurrentUserData,
@Body() dto: RemoveDeviceDto,
) {
await this.userService.removeDevice(
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
);
@ -198,14 +309,24 @@ export class UserAccountController {
@Get('by-referral-code/:code')
@ApiOperation({ summary: '根据推荐码查询用户' })
async getByReferralCode(@Param('code') code: string) {
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
return this.userService.getUserByReferralCode(
new GetUserByReferralCodeQuery(code),
);
}
@Get('wallet')
@ApiBearerAuth()
@ApiOperation({ summary: '获取我的钱包状态和地址' })
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto })
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto })
@ApiResponse({
status: 200,
description: '钱包已就绪',
type: WalletStatusReadyResponseDto,
})
@ApiResponse({
status: 202,
description: '钱包生成中',
type: WalletStatusGeneratingResponseDto,
})
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
return this.userService.getWalletStatus(
new GetWalletStatusQuery(user.accountSequence),
@ -214,7 +335,10 @@ export class UserAccountController {
@Post('wallet/retry')
@ApiBearerAuth()
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' })
@ApiOperation({
summary: '手动重试钱包生成',
description: '当钱包生成失败或超时时,用户可手动触发重试',
})
@ApiResponse({ status: 200, description: '重试请求已提交' })
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
await this.userService.retryWalletGeneration(user.userId);
@ -234,25 +358,43 @@ export class UserAccountController {
@Post('mnemonic/revoke')
@ApiBearerAuth()
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' })
@ApiOperation({
summary: '挂失助记词',
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);
}
@Post('freeze')
@ApiBearerAuth()
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' })
@ApiOperation({
summary: '冻结账户',
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);
}
@Post('unfreeze')
@ApiBearerAuth()
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' })
@ApiOperation({
summary: '解冻账户',
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({
userId: user.userId,
verifyMethod: dto.verifyMethod,
@ -264,9 +406,15 @@ export class UserAccountController {
@Post('key-rotation/request')
@ApiBearerAuth()
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' })
@ApiOperation({
summary: '请求密钥轮换',
description: '验证当前助记词后,请求轮换 MPC 密钥对',
})
@ApiResponse({ status: 200, description: '轮换请求结果' })
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) {
async requestKeyRotation(
@CurrentUser() user: CurrentUserData,
@Body() dto: RequestKeyRotationDto,
) {
return this.userService.requestKeyRotation({
userId: user.userId,
currentMnemonic: dto.currentMnemonic,
@ -276,9 +424,15 @@ export class UserAccountController {
@Post('backup-codes/generate')
@ApiBearerAuth()
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' })
@ApiOperation({
summary: '生成恢复码',
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({
userId: user.userId,
mnemonic: dto.mnemonic,
@ -300,7 +454,10 @@ export class UserAccountController {
@Post('sms/send-withdraw-code')
@ApiBearerAuth()
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' })
@ApiOperation({
summary: '发送提取验证短信',
description: '向用户绑定的手机号发送提取验证码',
})
@ApiResponse({ status: 200, description: '发送成功' })
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
await this.userService.sendWithdrawSmsCode(user.userId);
@ -309,31 +466,46 @@ export class UserAccountController {
@Post('sms/verify-withdraw-code')
@ApiBearerAuth()
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' })
@ApiOperation({
summary: '验证提取短信验证码',
description: '验证提取操作的短信验证码',
})
@ApiResponse({ status: 200, description: '验证结果' })
async verifyWithdrawSmsCode(
@CurrentUser() user: CurrentUserData,
@Body() body: { code: string },
) {
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code);
const valid = await this.userService.verifyWithdrawSmsCode(
user.userId,
body.code,
);
return { valid };
}
@Post('verify-password')
@ApiBearerAuth()
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' })
@ApiOperation({
summary: '验证登录密码',
description: '验证用户的登录密码,用于敏感操作二次验证',
})
@ApiResponse({ status: 200, description: '验证结果' })
async verifyPassword(
@CurrentUser() user: CurrentUserData,
@Body() body: { password: string },
) {
const valid = await this.userService.verifyPassword(user.userId, body.password);
const valid = await this.userService.verifyPassword(
user.userId,
body.password,
);
return { valid };
}
@Get('users/resolve-address/:accountSequence')
@ApiBearerAuth()
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' })
@ApiOperation({
summary: '解析充值ID到区块链地址',
description: '通过用户的 accountSequence 获取其区块链钱包地址',
})
@ApiResponse({ status: 200, description: '返回区块链地址' })
@ApiResponse({ status: 404, description: '找不到用户' })
async resolveAccountSequenceToAddress(
@ -376,7 +548,9 @@ export class UserAccountController {
// 验证文件类型
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';
// 其他通用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';
export class AutoLoginDto {
@ -144,7 +151,10 @@ export class RemoveDeviceDto {
// Response DTOs
export class AutoCreateAccountResponseDto {
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
})
userSerialNum: string;
@ApiProperty({ example: 'ABC123', description: '推荐码' })
@ -167,7 +177,10 @@ export class RecoverAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string;
@ApiProperty()
@ -206,7 +219,10 @@ export class WalletStatusReadyResponseDto {
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
walletAddresses: WalletAddressesDto;
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
@ApiProperty({
example: 'word1 word2 ... word12',
description: '助记词 (12词)',
})
mnemonic: string;
}
@ -220,7 +236,10 @@ export class LoginResponseDto {
@ApiProperty()
userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string;
@ApiProperty()
@ -233,7 +252,9 @@ export class LoginResponseDto {
// ============ Referral DTOs ============
export class GenerateReferralLinkDto {
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
@ApiPropertyOptional({
description: '渠道标识: wechat, telegram, twitter 等',
})
@IsOptional()
@IsString()
channel?: string;
@ -248,7 +269,10 @@ export class MeResponseDto {
@ApiProperty()
userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string;
@ApiProperty({ nullable: true })
@ -266,7 +290,11 @@ export class MeResponseDto {
@ApiProperty({ description: '完整推荐链接' })
referralLink: string;
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true })
@ApiProperty({
example: 'D2512110001',
description: '推荐人序列号',
nullable: true,
})
inviterSequence: string | null;
@ApiProperty({ description: '钱包地址列表' })
@ -291,7 +319,7 @@ export class ReferralValidationResponseDto {
@ApiPropertyOptional({ description: '邀请人信息' })
inviterInfo?: {
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string;
avatarUrl: string | null;
};
@ -324,7 +352,10 @@ export class ReferralLinkResponseDto {
}
export class InviteRecordDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string;
@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';
/**
@ -6,34 +12,40 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
*
*/
export interface DeviceNameDto {
model?: string; // 设备型号
platform?: string; // 平台: ios, android, web
osVersion?: string; // 系统版本
brand?: string; // 品牌
model?: string; // 设备型号
platform?: string; // 平台: ios, android, web
osVersion?: string; // 系统版本
brand?: string; // 品牌
manufacturer?: string; // 厂商
device?: string; // 设备名
product?: string; // 产品名
hardware?: string; // 硬件名
sdkInt?: number; // SDK 版本 (Android)
device?: string; // 设备名
product?: string; // 产品名
hardware?: string; // 硬件名
sdkInt?: number; // SDK 版本 (Android)
isPhysicalDevice?: boolean; // 是否真机
[key: string]: unknown; // 允许其他字段
}
export class AutoCreateAccountDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' })
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: '设备唯一标识',
})
@IsString()
@IsNotEmpty()
deviceId: string;
@ApiPropertyOptional({
description: '设备信息 (JSON 对象)',
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' },
})
@IsOptional()
@IsObject()
deviceName?: DeviceNameDto;
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
@ApiPropertyOptional({
example: 'RWAABC1234',
description: '邀请人推荐码 (6-20位大写字母和数字)',
})
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })

View File

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

View File

@ -11,3 +11,4 @@ export * from './generate-backup-codes.dto';
export * from './recover-by-backup-code.dto';
export * from './verify-sms-code.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: '恢复码' })
@IsString()
@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;
@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';
export class RecoverByMnemonicDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
@IsString()
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
@Matches(/^D\d{11}$/, {
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
})
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()
@IsNotEmpty()
mnemonic: string;

View File

@ -2,9 +2,14 @@ import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecoverByPhoneDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
@IsString()
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
@Matches(/^D\d{11}$/, {
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
})
accountSequence: string;
@ApiProperty({ example: '13800138000' })

View File

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

View File

@ -9,7 +9,10 @@ export class SubmitKycDto {
@ApiProperty({ example: '110101199001011234' })
@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;
@ApiProperty()

View File

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

View File

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

View File

@ -20,7 +20,10 @@ export class UserProfileDto {
@ApiProperty()
userId: string;
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
@ApiProperty({
example: 'D2512110001',
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
})
accountSequence: string;
@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 })
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
@ -12,7 +18,7 @@ export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
}
export function IsChinesePhone(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
@ -26,7 +32,9 @@ export function IsChinesePhone(validationOptions?: ValidationOptions) {
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
validate(idCard: string, args: ValidationArguments): boolean {
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard);
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 {
@ -35,7 +43,7 @@ export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
}
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,

View File

@ -6,7 +6,15 @@ import { ScheduleModule } from '@nestjs/schedule';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
// Config
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config';
import {
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
kafkaConfig,
smsConfig,
walletConfig,
} from '@/config';
// Controllers
import { UserAccountController } from '@/api/controllers/user-account.controller';
@ -25,7 +33,8 @@ import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
// Domain Services
import {
AccountSequenceGeneratorService, UserValidatorService,
AccountSequenceGeneratorService,
UserValidatorService,
} from '@/domain/services';
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.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 { SmsService } from '@/infrastructure/external/sms/sms.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';
// 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';
// ============ Infrastructure Module ============
@ -122,7 +137,13 @@ export class ApplicationModule {}
// ============ API Module ============
@Module({
imports: [ApplicationModule],
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController],
controllers: [
HealthController,
UserAccountController,
ReferralsController,
AuthController,
TotpController,
],
})
export class ApiModule {}
@ -131,14 +152,24 @@ export class ApiModule {}
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
kafkaConfig,
smsConfig,
walletConfig,
],
}),
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
},
}),
}),
InfrastructureModule,

View File

@ -1,8 +1,14 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
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 { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
import {
AccountSequenceGeneratorService,
UserValidatorService,
} from '@/domain/services';
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
@ -23,25 +29,34 @@ export class AutoCreateAccountHandler {
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}`);
// 1. 验证设备ID
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
command.deviceId,
);
if (!deviceCheck.isValid)
throw new ApplicationError(deviceCheck.errorMessage!);
// 2. 验证邀请码
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
const inviter = await this.userRepository.findByReferralCode(referralCode);
const referralValidation =
await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid)
throw new ApplicationError(referralValidation.errorMessage!);
const inviter =
await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence;
}
// 3. 生成用户序列号
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
const accountSequence =
await this.sequenceGenerator.generateNextUserSequence();
// 4. 生成用户名和头像
const identity = generateIdentity(accountSequence.value);
@ -52,7 +67,8 @@ export class AutoCreateAccountHandler {
const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model);
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(' ');
}
@ -61,7 +77,7 @@ export class AutoCreateAccountHandler {
accountSequence,
initialDeviceId: command.deviceId,
deviceName: deviceNameStr,
deviceInfo: command.deviceName, // 100% 保持原样存储
deviceInfo: command.deviceName, // 100% 保持原样存储
inviterSequence,
nickname: identity.username,
avatarSvg: identity.avatarSvg,
@ -81,7 +97,9 @@ export class AutoCreateAccountHandler {
await this.eventPublisher.publishAll(account.domainEvents);
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 {
userSerialNum: account.accountSequence.value,

View File

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

View File

@ -1,10 +1,10 @@
// ============ Types ============
// 设备信息输入 - 100% 保持前端传递的原样存储
export interface DeviceNameInput {
model?: string; // iPhone 15 Pro, Pixel 8
platform?: string; // ios, android, web
osVersion?: string; // iOS 17.2, Android 14
[key: string]: unknown; // 允许任意其他字段
model?: string; // iPhone 15 Pro, Pixel 8
platform?: string; // ios, android, web
osVersion?: string; // iOS 17.2, Android 14
[key: string]: unknown; // 允许任意其他字段
}
// ============ Commands ============
@ -18,7 +18,7 @@ export class AutoCreateAccountCommand {
export class RecoverByMnemonicCommand {
constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly mnemonic: string,
public readonly newDeviceId: string,
public readonly deviceName?: string,
@ -27,7 +27,7 @@ export class RecoverByMnemonicCommand {
export class RecoverByPhoneCommand {
constructor(
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly phoneNumber: string,
public readonly smsCode: string,
public readonly newDeviceId: string,
@ -144,13 +144,13 @@ export class GetReferralStatsQuery {
export class GenerateReferralLinkCommand {
constructor(
public readonly userId: string,
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
public readonly campaignId?: string, // 活动ID
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
public readonly campaignId?: string, // 活动ID
) {}
}
export class GetWalletStatusQuery {
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
}
export class MarkMnemonicBackedUpCommand {
@ -184,21 +184,21 @@ export interface WalletStatusResult {
dst: string;
bsc: string;
};
mnemonic?: string; // 助记词 (ready 状态时返回)
errorMessage?: string; // 失败原因 (failed 状态时返回)
mnemonic?: string; // 助记词 (ready 状态时返回)
errorMessage?: string; // 失败原因 (failed 状态时返回)
}
export interface AutoCreateAccountResult {
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
referralCode: string; // 推荐码
username: string; // 随机用户名
avatarSvg: string; // 随机SVG头像
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
referralCode: string; // 推荐码
username: string; // 随机用户名
avatarSvg: string; // 随机SVG头像
accessToken: string;
refreshToken: string;
}
export interface RecoverAccountResult {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string;
avatarUrl: string | null;
referralCode: string;
@ -208,14 +208,14 @@ export interface RecoverAccountResult {
export interface AutoLoginResult {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accessToken: string;
refreshToken: string;
}
export interface RegisterResult {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string;
accessToken: string;
refreshToken: string;
@ -223,14 +223,14 @@ export interface RegisterResult {
export interface LoginResult {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accessToken: string;
refreshToken: string;
}
export interface UserProfileDTO {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
phoneNumber: string | null;
nickname: string;
avatarUrl: string | null;
@ -253,7 +253,7 @@ export interface DeviceDTO {
export interface UserBriefDTO {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string;
avatarUrl: string | null;
}
@ -262,7 +262,7 @@ export interface ReferralCodeValidationResult {
valid: boolean;
referralCode?: string;
inviterInfo?: {
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string;
avatarUrl: string | null;
};
@ -281,29 +281,30 @@ export interface ReferralLinkResult {
export interface ReferralStatsResult {
referralCode: string;
totalInvites: number; // 总邀请人数
directInvites: number; // 直接邀请人数
indirectInvites: number; // 间接邀请人数 (二级)
todayInvites: number; // 今日邀请
thisWeekInvites: number; // 本周邀请
thisMonthInvites: number; // 本月邀请
recentInvites: Array<{ // 最近邀请记录
accountSequence: string; // 格式: D + YYMMDD + 5位序号
totalInvites: number; // 总邀请人数
directInvites: number; // 直接邀请人数
indirectInvites: number; // 间接邀请人数 (二级)
todayInvites: number; // 今日邀请
thisWeekInvites: number; // 本周邀请
thisMonthInvites: number; // 本月邀请
recentInvites: Array<{
// 最近邀请记录
accountSequence: string; // 格式: D + YYMMDD + 5位序号
nickname: string;
avatarUrl: string | null;
registeredAt: Date;
level: number; // 1=直接, 2=间接
level: number; // 1=直接, 2=间接
}>;
}
export interface MeResult {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
phoneNumber: string | null;
nickname: string;
avatarUrl: string | null;
referralCode: string;
referralLink: string; // 完整推荐链接
referralLink: string; // 完整推荐链接
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
walletAddresses: Array<{ chainType: string; address: string }>;
kycStatus: string;

View File

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

View File

@ -1,6 +1,9 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
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 { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
@ -21,34 +24,49 @@ export class RecoverByMnemonicHandler {
private readonly blockchainClient: BlockchainClientService,
) {}
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
async execute(
command: RecoverByMnemonicCommand,
): Promise<RecoverAccountResult> {
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.isActive) throw new ApplicationError('账户已冻结或注销');
// 调用 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({
accountSequence: command.accountSequence,
mnemonic: command.mnemonic,
});
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 || '助记词错误');
}
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`);
this.logger.log(
`Mnemonic verified successfully for account ${command.accountSequence}`,
);
// 如果头像为空,重新生成一个
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) {
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) {
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();
account.updateProfile({ avatarUrl });
}
@ -76,8 +94,12 @@ export class RecoverByMnemonicHandler {
refreshToken: tokens.refreshToken,
};
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`);
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`);
this.logger.log(
`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
);
this.logger.log(
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
);
return result;
}

View File

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

View File

@ -1,6 +1,9 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
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 { TokenService } from '@/application/services/token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
@ -23,21 +26,29 @@ export class RecoverByPhoneHandler {
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
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.isActive) throw new ApplicationError('账户已冻结或注销');
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
if (!account.phoneNumber)
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
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}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const cachedCode = await this.redisService.get(
`sms:recover:${phoneNumber.value}`,
);
if (cachedCode !== command.smsCode)
throw new ApplicationError('验证码错误或已过期');
// 如果头像为空,重新生成一个
let avatarUrl = account.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();
account.updateProfile({ avatarUrl });
}

View File

@ -11,7 +11,10 @@
*/
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 { ChainType, UserId } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service';
@ -31,7 +34,7 @@ interface WalletCompletedStatusData {
userId: string;
publicKey?: string;
walletAddresses?: { chainType: string; address: string }[];
mnemonic?: string; // 恢复助记词 (明文,仅首次)
mnemonic?: string; // 恢复助记词 (明文,仅首次)
updatedAt: string;
}
@ -49,8 +52,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
async onModuleInit() {
// Register event handler
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this));
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events');
this.blockchainEventConsumer.onWalletAddressCreated(
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)
* - BSC: 0x... (EVM)
*/
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload;
private async handleWalletAddressCreated(
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] Addresses: ${JSON.stringify(addresses)}`);
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
if (!userId) {
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
this.logger.error(
'[ERROR] WalletAddressCreated event missing userId, skipping',
);
return;
}
if (!addresses || addresses.length === 0) {
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping');
this.logger.error(
'[ERROR] WalletAddressCreated event missing addresses, skipping',
);
return;
}
@ -90,23 +112,29 @@ export class BlockchainWalletHandler implements OnModuleInit {
// 2. Create wallet addresses for each chain (with publicKey)
const wallets: WalletAddress[] = addresses.map((addr) => {
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({
userId: account.userId,
chainType,
address: addr.address,
publicKey, // 传入公钥,用于关联助记词
publicKey, // 传入公钥,用于关联助记词
});
});
// 3. Save wallet addresses to user account
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)
// Note: blockchain-service stores mnemonic with accountSequence association
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)
@ -116,7 +144,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
userId,
publicKey,
walletAddresses: addresses,
mnemonic, // 首次返回明文助记词
mnemonic, // 首次返回明文助记词
updatedAt: new Date().toISOString(),
};
@ -128,9 +156,13 @@ export class BlockchainWalletHandler implements OnModuleInit {
);
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 {
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
@ -138,7 +170,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
});
} 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
// This ensures messages are not marked as consumed until successfully processed
throw error;
@ -158,9 +193,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
case 'BSC':
return ChainType.BSC;
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;
}
}
}

View File

@ -25,7 +25,12 @@ import {
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
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 {
status: KeygenStatus;
@ -48,9 +53,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
async onModuleInit() {
// Register event handlers
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.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"
*/
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> {
private async handleKeygenStarted(
payload: KeygenStartedPayload,
): Promise<void> {
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 {
const statusData: KeygenStatusData = {
@ -76,9 +89,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
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) {
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
throw error;
}
@ -94,7 +112,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* Uses atomic Redis update to ensure status only advances forward:
* pending -> generating -> deriving -> completed
*/
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
private async handleKeygenCompleted(
payload: KeygenCompletedPayload,
): Promise<void> {
const { publicKey, extraPayload } = payload;
if (!extraPayload?.userId) {
@ -103,11 +123,15 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
}
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)}...`);
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
// Uses atomic operation to ensure we don't overwrite higher-priority status
@ -126,13 +150,22 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
);
if (updated) {
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
this.logger.log(
`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`,
);
this.logger.log(
`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`,
);
} 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) {
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
throw error;
}
@ -145,7 +178,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* 1. Log error
* 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;
// Only handle keygen failures
@ -154,7 +189,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
}
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 {
// Update Redis status to failed
@ -171,9 +208,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
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) {
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
throw error;
}

View File

@ -1,6 +1,9 @@
import { Injectable, Inject } from '@nestjs/common';
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 { ApplicationError } from '@/shared/exceptions/domain.exception';
import { DeviceDTO } from '@/application/commands';
@ -13,7 +16,9 @@ export class GetMyDevicesHandler {
) {}
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('用户不存在');
return account.getAllDevices().map((device) => ({

View File

@ -1,6 +1,9 @@
import { Injectable, Inject } from '@nestjs/common';
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 { UserId } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
@ -14,7 +17,9 @@ export class GetMyProfileHandler {
) {}
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('用户不存在');
return this.toDTO(account);
}
@ -33,7 +38,10 @@ export class GetMyProfileHandler {
})),
kycStatus: account.kycStatus,
kycInfo: account.kycInfo
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
? {
realName: account.kycInfo.realName,
idCardNumber: account.kycInfo.maskedIdCardNumber(),
}
: null,
status: account.status,
registeredAt: account.registeredAt,

View File

@ -7,7 +7,7 @@ import { ApplicationError } from '@/shared/exceptions/domain.exception';
export interface TokenPayload {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string;
type: 'access' | 'refresh';
}
@ -22,17 +22,27 @@ export class TokenService {
async generateTokenPair(payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string;
}): Promise<{ accessToken: string; refreshToken: string }> {
const accessToken = this.jwtService.sign(
{ ...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(
{ ...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

View File

@ -11,13 +11,14 @@ export class TotpService {
private readonly logger = new Logger(TotpService.name);
// TOTP 配置
private readonly TOTP_DIGITS = 6; // 验证码位数
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
private readonly ISSUER = 'RWADurian'; // 应用名称
private readonly TOTP_DIGITS = 6; // 验证码位数
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
private readonly ISSUER = 'RWADurian'; // 应用名称
// 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) {}

View File

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

View File

@ -19,7 +19,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { RedisService } from '@/infrastructure/redis/redis.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 { UserId } from '@/domain/value-objects';
@ -64,7 +67,9 @@ export class WalletRetryTask {
async handleWalletRetry() {
// 防止并发执行
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;
}
@ -73,8 +78,12 @@ export class WalletRetryTask {
try {
// 1. 扫描所有 keygen:status:* keys
const statusKeys = await this.redisService.keys(`${KEYGEN_STATUS_PREFIX}*`);
this.logger.log(`[TASK] Found ${statusKeys.length} wallet generation records`);
const statusKeys = await this.redisService.keys(
`${KEYGEN_STATUS_PREFIX}*`,
);
this.logger.log(
`[TASK] Found ${statusKeys.length} wallet generation records`,
);
for (const key of statusKeys) {
try {
@ -123,7 +132,9 @@ export class WalletRetryTask {
// 检查重试限制
const canRetry = await this.checkRetryLimit(userId);
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);
return;
@ -144,19 +155,25 @@ export class WalletRetryTask {
// 情况1状态为 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;
}
// 情况2状态为 generating 但超过 60 秒
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;
}
// 情况3状态为 deriving 但超过 60 秒
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;
}
@ -189,7 +206,9 @@ export class WalletRetryTask {
// 如果超过 10 分钟,不再重试
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;
}
@ -223,7 +242,9 @@ export class WalletRetryTask {
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等待重新生成
const statusData: KeygenStatusData = {
@ -238,7 +259,10 @@ export class WalletRetryTask {
60 * 60 * 24, // 24 小时
);
} 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 小时
);
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 小时
);
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 {
UserId, AccountSequence, PhoneNumber, ReferralCode,
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
UserId,
AccountSequence,
PhoneNumber,
ReferralCode,
DeviceInfo,
ChainType,
KYCInfo,
KYCStatus,
AccountStatus,
} from '@/domain/value-objects';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
UserAccountFrozenEvent, UserAccountDeactivatedEvent,
DomainEvent,
UserAccountAutoCreatedEvent,
UserAccountCreatedEvent,
DeviceAddedEvent,
DeviceRemovedEvent,
PhoneNumberBoundEvent,
WalletAddressBoundEvent,
MultipleWalletAddressesBoundEvent,
KYCSubmittedEvent,
KYCVerifiedEvent,
KYCRejectedEvent,
UserAccountFrozenEvent,
UserAccountDeactivatedEvent,
} from '@/domain/events';
export class UserAccount {
@ -31,30 +46,71 @@ export class UserAccount {
private _domainEvents: DomainEvent[] = [];
// Getters
get userId(): UserId { return this._userId; }
get accountSequence(): AccountSequence { return this._accountSequence; }
get phoneNumber(): PhoneNumber | null { return this._phoneNumber; }
get nickname(): string { return this._nickname; }
get avatarUrl(): string | null { return this._avatarUrl; }
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]; }
get userId(): UserId {
return this._userId;
}
get accountSequence(): AccountSequence {
return this._accountSequence;
}
get phoneNumber(): PhoneNumber | null {
return this._phoneNumber;
}
get nickname(): string {
return this._nickname;
}
get avatarUrl(): string | null {
return this._avatarUrl;
}
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(
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>,
phoneNumber: PhoneNumber | null, nickname: string, 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,
userId: UserId,
accountSequence: AccountSequence,
devices: Map<string, DeviceInfo>,
phoneNumber: PhoneNumber | null,
nickname: string,
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._accountSequence = accountSequence;
@ -77,37 +133,56 @@ export class UserAccount {
accountSequence: AccountSequence;
initialDeviceId: string;
deviceName?: string;
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
inviterSequence: AccountSequence | null;
nickname?: string;
avatarSvg?: string;
}): UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.deviceInfo, // 传递完整的 JSON
));
devices.set(
params.initialDeviceId,
new DeviceInfo(
params.initialDeviceId,
params.deviceName || '未命名设备',
new Date(),
new Date(),
params.deviceInfo, // 传递完整的 JSON
),
);
// UserID将由数据库自动生成(autoincrement)这里使用临时值0
const nickname = params.nickname || `用户${params.accountSequence.dailySequence}`;
const nickname =
params.nickname || `用户${params.accountSequence.dailySequence}`;
const avatarUrl = params.avatarSvg || null;
const account = new UserAccount(
UserId.create(0), params.accountSequence, devices, null,
nickname, avatarUrl, params.inviterSequence,
UserId.create(0),
params.accountSequence,
devices,
null,
nickname,
avatarUrl,
params.inviterSequence,
ReferralCode.generate(),
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
new Date(), null, new Date(),
new Map(),
null,
KYCStatus.NOT_VERIFIED,
AccountStatus.ACTIVE,
new Date(),
null,
new Date(),
);
account.addDomainEvent(new UserAccountAutoCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
}));
account.addDomainEvent(
new UserAccountAutoCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
}),
);
return account;
}
@ -117,50 +192,77 @@ export class UserAccount {
phoneNumber: PhoneNumber;
initialDeviceId: string;
deviceName?: string;
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
inviterSequence: AccountSequence | null;
}): UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.deviceInfo,
));
devices.set(
params.initialDeviceId,
new DeviceInfo(
params.initialDeviceId,
params.deviceName || '未命名设备',
new Date(),
new Date(),
params.deviceInfo,
),
);
// UserID将由数据库自动生成(autoincrement)这里使用临时值0
const account = new UserAccount(
UserId.create(0), params.accountSequence, devices, params.phoneNumber,
`用户${params.accountSequence.dailySequence}`, null, params.inviterSequence,
UserId.create(0),
params.accountSequence,
devices,
params.phoneNumber,
`用户${params.accountSequence.dailySequence}`,
null,
params.inviterSequence,
ReferralCode.generate(),
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
new Date(), null, new Date(),
new Map(),
null,
KYCStatus.NOT_VERIFIED,
AccountStatus.ACTIVE,
new Date(),
null,
new Date(),
);
account.addDomainEvent(new UserAccountCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
phoneNumber: params.phoneNumber.value,
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
}));
account.addDomainEvent(
new UserAccountCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
phoneNumber: params.phoneNumber.value,
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
}),
);
return account;
}
static reconstruct(params: {
userId: string; accountSequence: string; devices: DeviceInfo[];
phoneNumber: string | null; nickname: string; avatarUrl: string | null;
inviterSequence: string | null; referralCode: string;
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null;
kycStatus: KYCStatus; status: AccountStatus;
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
userId: string;
accountSequence: string;
devices: DeviceInfo[];
phoneNumber: string | null;
nickname: string;
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 {
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>();
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w));
params.walletAddresses.forEach((w) => walletMap.set(w.chainType, w));
return new UserAccount(
UserId.create(params.userId),
@ -169,7 +271,9 @@ export class UserAccount {
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
params.nickname,
params.avatarUrl,
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null,
params.inviterSequence
? AccountSequence.create(params.inviterSequence)
: null,
ReferralCode.create(params.referralCode),
walletMap,
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();
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainError('最多允许5个设备同时登录');
@ -193,15 +301,24 @@ export class UserAccount {
device.updateDeviceInfo(deviceInfo);
}
} else {
this._devices.set(deviceId, new DeviceInfo(
deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo,
));
this.addDomainEvent(new DeviceAddedEvent({
userId: this.userId.toString(),
accountSequence: this.accountSequence.value,
this._devices.set(
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();
}
@ -212,7 +329,9 @@ export class UserAccount {
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
this._devices.delete(deviceId);
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 {
@ -235,54 +354,90 @@ export class UserAccount {
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
this._phoneNumber = phoneNumber;
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 {
this.ensureActive();
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
if (this._walletAddresses.has(chainType))
throw new DomainError(`已绑定${chainType}地址`);
const walletAddress = WalletAddress.create({
userId: this.userId,
chainType,
address,
});
this._walletAddresses.set(chainType, walletAddress);
this._updatedAt = new Date();
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.toString(), chainType, address }));
this.addDomainEvent(
new WalletAddressBoundEvent({
userId: this.userId.toString(),
chainType,
address,
}),
);
}
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
this.ensureActive();
for (const [chainType, wallet] of wallets) {
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
if (this._walletAddresses.has(chainType))
throw new DomainError(`已绑定${chainType}地址`);
this._walletAddresses.set(chainType, wallet);
}
this._updatedAt = new Date();
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({
userId: this.userId.toString(),
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })),
}));
this.addDomainEvent(
new MultipleWalletAddressesBoundEvent({
userId: this.userId.toString(),
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({
chainType,
address: wallet.address,
})),
}),
);
}
submitKYC(kycInfo: KYCInfo): void {
this.ensureActive();
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交');
if (this._kycStatus === KYCStatus.VERIFIED)
throw new DomainError('已通过KYC认证,不可重复提交');
this._kycInfo = kycInfo;
this._kycStatus = KYCStatus.PENDING;
this._updatedAt = new Date();
this.addDomainEvent(new KYCSubmittedEvent({
userId: this.userId.toString(), realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber,
}));
this.addDomainEvent(
new KYCSubmittedEvent({
userId: this.userId.toString(),
realName: kycInfo.realName,
idCardNumber: kycInfo.idCardNumber,
}),
);
}
approveKYC(): void {
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC');
if (this._kycStatus !== KYCStatus.PENDING)
throw new DomainError('只有待审核状态才能通过KYC');
this._kycStatus = KYCStatus.VERIFIED;
this._updatedAt = new Date();
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.toString(), verifiedAt: new Date() }));
this.addDomainEvent(
new KYCVerifiedEvent({
userId: this.userId.toString(),
verifiedAt: new Date(),
}),
);
}
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._updatedAt = new Date();
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.toString(), reason }));
this.addDomainEvent(
new KYCRejectedEvent({ userId: this.userId.toString(), reason }),
);
}
recordLogin(): void {
@ -292,23 +447,33 @@ export class UserAccount {
}
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._updatedAt = new Date();
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }));
this.addDomainEvent(
new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }),
);
}
unfreeze(): void {
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结');
if (this._status !== AccountStatus.FROZEN)
throw new DomainError('账户未冻结');
this._status = AccountStatus.ACTIVE;
this._updatedAt = new Date();
}
deactivate(): void {
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销');
if (this._status === AccountStatus.DEACTIVATED)
throw new DomainError('账户已注销');
this._status = AccountStatus.DEACTIVATED;
this._updatedAt = new Date();
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.toString(), deactivatedAt: new Date() }));
this.addDomainEvent(
new UserAccountDeactivatedEvent({
userId: this.userId.toString(),
deactivatedAt: new Date(),
}),
);
}
getWalletAddress(chainType: ChainType): WalletAddress | null {
@ -320,7 +485,8 @@ export class UserAccount {
}
private ensureActive(): void {
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
if (this._status !== AccountStatus.ACTIVE)
throw new DomainError('账户已冻结或注销');
}
private addDomainEvent(event: DomainEvent): void {
@ -339,7 +505,9 @@ export class UserAccount {
*/
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({
userId: this._userId.toString(),

View File

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

View File

@ -14,10 +14,10 @@ export class UserAccountAutoCreatedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: Date;
},
) {
@ -33,11 +33,11 @@ export class UserAccountCreatedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
phoneNumber: string;
initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: Date;
},
) {
@ -53,7 +53,7 @@ export class DeviceAddedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string;
deviceName: string;
},
@ -77,7 +77,9 @@ export class DeviceRemovedEvent extends DomainEvent {
}
export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
constructor(
public readonly payload: { userId: string; phoneNumber: string },
) {
super();
}
@ -87,7 +89,13 @@ export class PhoneNumberBoundEvent 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();
}
@ -112,7 +120,13 @@ export class MultipleWalletAddressesBoundEvent 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();
}
@ -152,7 +166,9 @@ export class UserAccountFrozenEvent extends DomainEvent {
}
export class UserAccountDeactivatedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) {
constructor(
public readonly payload: { userId: string; deactivatedAt: Date },
) {
super();
}
@ -201,7 +217,7 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
public readonly payload: {
sessionId: string;
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
username: string;
threshold: number;
totalParties: number;

View File

@ -1,7 +1,9 @@
import { DomainEvent } from './index';
export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
constructor(
public readonly payload: { userId: string; phoneNumber: string },
) {
super();
}

View File

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

View File

@ -1,5 +1,8 @@
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';
@Injectable()

View File

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

View File

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

View File

@ -10,7 +10,9 @@ export class AccountSequence {
constructor(public readonly value: string) {
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 addedAt: Date,
lastActiveAt: Date,
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
) {
this._lastActiveAt = lastActiveAt;
}

View File

@ -1,5 +1,11 @@
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 { wordlist } from '@scure/bip39/wordlists/english';
@ -144,7 +150,9 @@ export class DeviceInfo {
}
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 {
@ -188,13 +196,27 @@ export class KYCInfo {
if (!realName || realName.length < 2) {
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('身份证号格式错误');
}
}
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
static create(params: {
realName: string;
idCardNumber: string;
idCardFrontUrl: string;
idCardBackUrl: string;
}): KYCInfo {
return new KYCInfo(
params.realName,
params.idCardNumber,
params.idCardFrontUrl,
params.idCardBackUrl,
);
}
maskedIdCardNumber(): string {
@ -255,7 +277,11 @@ export class MnemonicEncryption {
static decrypt(encryptedData: string, key: string): string {
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
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'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');

View File

@ -10,13 +10,27 @@ export class KYCInfo {
if (!realName || realName.length < 2) {
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('身份证号格式错误');
}
}
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
static create(params: {
realName: string;
idCardNumber: string;
idCardFrontUrl: string;
idCardBackUrl: string;
}): KYCInfo {
return new KYCInfo(
params.realName,
params.idCardNumber,
params.idCardFrontUrl,
params.idCardBackUrl,
);
}
maskedIdCardNumber(): string {

View File

@ -11,7 +11,7 @@ describe('Mnemonic ValueObject', () => {
const words = mnemonic.getWords();
expect(words).toHaveLength(12);
expect(words.every(word => word.length > 0)).toBe(true);
expect(words.every((word) => word.length > 0)).toBe(true);
});
it('生成的助记词应该能转换为 seed', () => {
@ -33,7 +33,8 @@ describe('Mnemonic ValueObject', () => {
describe('create', () => {
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);
expect(mnemonic.value).toBe(validMnemonic);
@ -54,7 +55,8 @@ describe('Mnemonic ValueObject', () => {
});
it('应该拒绝非英文单词', () => {
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
const invalidMnemonic =
'中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
expect(() => {
Mnemonic.create(invalidMnemonic);
@ -74,7 +76,8 @@ describe('Mnemonic ValueObject', () => {
describe('toSeed', () => {
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 mnemonic2 = Mnemonic.create(mnemonicStr);
@ -97,7 +100,8 @@ describe('Mnemonic ValueObject', () => {
describe('equals', () => {
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 mnemonic2 = Mnemonic.create(mnemonicStr);

View File

@ -12,7 +12,7 @@ describe('PhoneNumber ValueObject', () => {
'19900003333',
];
validPhones.forEach(phone => {
validPhones.forEach((phone) => {
const phoneNumber = PhoneNumber.create(phone);
expect(phoneNumber.value).toBe(phone);
});
@ -20,15 +20,15 @@ describe('PhoneNumber ValueObject', () => {
it('应该拒绝无效的手机号格式', () => {
const invalidPhones = [
'12800138000', // 不是1开头
'1380013800', // 少于11位
'12800138000', // 不是1开头
'1380013800', // 少于11位
'138001380000', // 多于11位
'10800138000', // 第二位不是3-9
'abcdefghijk', // 非数字
'', // 空字符串
'10800138000', // 第二位不是3-9
'abcdefghijk', // 非数字
'', // 空字符串
];
invalidPhones.forEach(phone => {
invalidPhones.forEach((phone) => {
expect(() => {
PhoneNumber.create(phone);
}).toThrow(DomainError);
@ -42,7 +42,7 @@ describe('PhoneNumber ValueObject', () => {
'+8613800138000',
];
invalidPhones.forEach(phone => {
invalidPhones.forEach((phone) => {
expect(() => {
PhoneNumber.create(phone);
}).toThrow(DomainError);

View File

@ -26,7 +26,7 @@ export interface VerifyMnemonicResult {
}
export interface VerifyMnemonicByAccountParams {
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
mnemonic: string;
}
@ -60,8 +60,12 @@ export class BlockchainClientService {
/**
*
*/
async verifyMnemonic(params: VerifyMnemonicParams): Promise<VerifyMnemonicResult> {
this.logger.log(`Verifying mnemonic against ${params.expectedAddresses.length} addresses`);
async verifyMnemonic(
params: VerifyMnemonicParams,
): Promise<VerifyMnemonicResult> {
this.logger.log(
`Verifying mnemonic against ${params.expectedAddresses.length} addresses`,
);
try {
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;
} catch (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}`);
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;
} catch (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;
} catch (error) {
this.logger.error('Failed to derive addresses from mnemonic', error);
@ -145,7 +157,9 @@ export class BlockchainClientService {
*
*/
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 {
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) {
this.logger.error('Failed to mark mnemonic as backed up', error);
throw error;
@ -169,8 +185,13 @@ export class BlockchainClientService {
/**
*
*/
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> {
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`);
async revokeMnemonic(
accountSequence: string,
reason: string,
): Promise<{ success: boolean; message: string }> {
this.logger.log(
`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`,
);
try {
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;
} catch (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 { firstValueFrom } from 'rxjs';
import { createHash, randomUUID } from 'crypto';
import { EventPublisherService, IDENTITY_TOPICS } from '../../kafka/event-publisher.service';
import {
EventPublisherService,
IDENTITY_TOPICS,
} from '../../kafka/event-publisher.service';
import {
MpcEventConsumerService,
KeygenCompletedPayload,
@ -38,34 +41,34 @@ export const MPC_REQUEST_TOPICS = {
export interface KeygenRequest {
sessionId: string;
username: string; // 用户名 (自动递增ID)
threshold: number; // t in t-of-n (默认 1, 即 2-of-3)
totalParties: number; // n in t-of-n (默认 3)
requireDelegate: boolean; // 是否需要 delegate party
username: string; // 用户名 (自动递增ID)
threshold: number; // t in t-of-n (默认 1, 即 2-of-3)
totalParties: number; // n in t-of-n (默认 3)
requireDelegate: boolean; // 是否需要 delegate party
}
export interface KeygenResult {
sessionId: string;
publicKey: string; // 压缩格式公钥 (33 bytes hex)
delegateShare: DelegateShare; // delegate share (用户分片)
serverParties: string[]; // 服务器 party IDs
publicKey: string; // 压缩格式公钥 (33 bytes hex)
delegateShare: DelegateShare; // delegate share (用户分片)
serverParties: string[]; // 服务器 party IDs
}
export interface DelegateShare {
partyId: string;
partyIndex: number;
encryptedShare: string; // 加密的分片数据 (hex)
encryptedShare: string; // 加密的分片数据 (hex)
}
export interface SigningRequest {
username: string;
messageHash: string; // 32 bytes hex
userShare?: string; // 如果账户有 delegate share需要传入用户分片
messageHash: string; // 32 bytes hex
userShare?: string; // 如果账户有 delegate share需要传入用户分片
}
export interface SigningResult {
sessionId: string;
signature: string; // 64 bytes hex (R + S)
signature: string; // 64 bytes hex (R + S)
messageHash: string;
}
@ -96,13 +99,19 @@ export interface AsyncSigningResponse {
}
// 结果回调类型
export type KeygenResultCallback = (result: KeygenResult | null, error?: string) => Promise<void>;
export type SigningResultCallback = (result: SigningResult | null, error?: string) => Promise<void>;
export type KeygenResultCallback = (
result: KeygenResult | null,
error?: string,
) => Promise<void>;
export type SigningResultCallback = (
result: SigningResult | null,
error?: string,
) => Promise<void>;
@Injectable()
export class MpcClientService implements OnModuleInit {
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 useEventDriven: boolean;
private readonly pollIntervalMs = 2000;
@ -110,7 +119,8 @@ export class MpcClientService implements OnModuleInit {
// 待处理的 keygen/signing 请求回调
private pendingKeygenCallbacks: Map<string, KeygenResultCallback> = new Map();
private pendingSigningCallbacks: Map<string, SigningResultCallback> = new Map();
private pendingSigningCallbacks: Map<string, SigningResultCallback> =
new Map();
constructor(
private readonly httpService: HttpService,
@ -119,15 +129,23 @@ export class MpcClientService implements OnModuleInit {
private readonly mpcEventConsumer: MpcEventConsumerService,
) {
// 连接 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.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() {
// 注册 MPC 事件处理器
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
this.mpcEventConsumer.onSigningCompleted(this.handleSigningCompleted.bind(this));
this.mpcEventConsumer.onKeygenCompleted(
this.handleKeygenCompleted.bind(this),
);
this.mpcEventConsumer.onSigningCompleted(
this.handleSigningCompleted.bind(this),
);
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
this.logger.log('MPC event handlers registered');
}
@ -146,7 +164,9 @@ export class MpcClientService implements OnModuleInit {
): Promise<AsyncKeygenResponse> {
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') {
@ -189,7 +209,9 @@ export class MpcClientService implements OnModuleInit {
): Promise<AsyncSigningResponse> {
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') {
@ -226,7 +248,9 @@ export class MpcClientService implements OnModuleInit {
// 事件处理器 - 处理 MPC 完成事件
// ==========================================================================
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
private async handleKeygenCompleted(
payload: KeygenCompletedPayload,
): Promise<void> {
const sessionId = payload.sessionId;
const callback = this.pendingKeygenCallbacks.get(sessionId);
@ -246,14 +270,19 @@ export class MpcClientService implements OnModuleInit {
};
await callback(result);
} catch (error) {
this.logger.error(`Keygen callback error: sessionId=${sessionId}`, error);
this.logger.error(
`Keygen callback error: sessionId=${sessionId}`,
error,
);
} finally {
this.pendingKeygenCallbacks.delete(sessionId);
}
}
}
private async handleSigningCompleted(payload: SigningCompletedPayload): Promise<void> {
private async handleSigningCompleted(
payload: SigningCompletedPayload,
): Promise<void> {
const sessionId = payload.sessionId;
const callback = this.pendingSigningCallbacks.get(sessionId);
@ -268,18 +297,25 @@ export class MpcClientService implements OnModuleInit {
};
await callback(result);
} catch (error) {
this.logger.error(`Signing callback error: sessionId=${sessionId}`, error);
this.logger.error(
`Signing callback error: sessionId=${sessionId}`,
error,
);
} finally {
this.pendingSigningCallbacks.delete(sessionId);
}
}
}
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
private async handleSessionFailed(
payload: SessionFailedPayload,
): Promise<void> {
const sessionId = payload.sessionId;
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') {
const callback = this.pendingKeygenCallbacks.get(sessionId);
@ -318,7 +354,10 @@ export class MpcClientService implements OnModuleInit {
}
} catch (error) {
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) {
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
*/
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') {
@ -403,7 +447,9 @@ export class MpcClientService implements OnModuleInit {
const sessionResult = await this.pollKeygenStatus(sessionId);
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}`);
@ -415,7 +461,10 @@ export class MpcClientService implements OnModuleInit {
serverParties: sessionResult.serverParties,
};
} 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}`);
}
}
@ -442,12 +491,9 @@ export class MpcClientService implements OnModuleInit {
encryptedShare: string;
};
serverParties?: string[];
}>(
`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`,
{
timeout: 10000,
},
),
}>(`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`, {
timeout: 10000,
}),
);
const data = response.data;
@ -457,7 +503,11 @@ export class MpcClientService implements OnModuleInit {
return {
status: 'completed',
publicKey: data.publicKey || '',
delegateShare: data.delegateShare || { partyId: '', partyIndex: -1, encryptedShare: '' },
delegateShare: data.delegateShare || {
partyId: '',
partyIndex: -1,
encryptedShare: '',
},
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
*/
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') {
@ -523,7 +577,9 @@ export class MpcClientService implements OnModuleInit {
const signResult = await this.pollSigningStatus(sessionId);
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 {
@ -532,7 +588,10 @@ export class MpcClientService implements OnModuleInit {
messageHash: request.messageHash,
};
} 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}`);
}
}
@ -551,12 +610,9 @@ export class MpcClientService implements OnModuleInit {
sessionId: string;
status: string;
signature?: string;
}>(
`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`,
{
timeout: 10000,
},
),
}>(`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`, {
timeout: 10000,
}),
);
const data = response.data;
@ -586,14 +642,16 @@ export class MpcClientService implements OnModuleInit {
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* MPC keygen ()
*/
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');
@ -602,13 +660,19 @@ export class MpcClientService implements OnModuleInit {
const publicKey = wallet.publicKey;
// 压缩公钥 (33 bytes)
const compressedPubKey = ethers.SigningKey.computePublicKey(publicKey, true);
const compressedPubKey = ethers.SigningKey.computePublicKey(
publicKey,
true,
);
// 模拟 delegate share
const delegateShare: DelegateShare = {
partyId: 'delegate-party',
partyIndex: 2,
encryptedShare: this.encryptShareData(wallet.privateKey, request.username),
encryptedShare: this.encryptShareData(
wallet.privateKey,
request.username,
),
};
return {
@ -623,7 +687,9 @@ export class MpcClientService implements OnModuleInit {
* MPC ()
*/
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');

View File

@ -13,7 +13,7 @@ import { MpcClientService } from './mpc-client.service';
export interface MpcWalletGenerationParams {
userId: string;
username: string; // 用户名 (用于 MPC keygen)
username: string; // 用户名 (用于 MPC keygen)
deviceId: string;
}
@ -22,15 +22,15 @@ export interface ChainWalletInfo {
address: string;
publicKey: string;
addressDigest: string;
signature: string; // 64 bytes hex (R + S)
signature: string; // 64 bytes hex (R + S)
}
export interface MpcWalletGenerationResult {
publicKey: string; // MPC 公钥
delegateShare: string; // delegate share (加密的用户分片)
serverParties: string[]; // 服务器 party IDs
wallets: ChainWalletInfo[]; // 三条链的钱包信息
sessionId: string; // MPC 会话ID
publicKey: string; // MPC 公钥
delegateShare: string; // delegate share (加密的用户分片)
serverParties: string[]; // 服务器 party IDs
wallets: ChainWalletInfo[]; // 三条链的钱包信息
sessionId: string; // MPC 会话ID
}
@Injectable()
@ -53,15 +53,13 @@ export class MpcWalletService {
},
DST: {
name: 'Durian Star Token',
prefix: 'dst', // Cosmos Bech32 前缀
prefix: 'dst', // Cosmos Bech32 前缀
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
addressType: 'cosmos' as const,
},
};
constructor(
private readonly mpcClient: MpcClientService,
) {}
constructor(private readonly mpcClient: MpcClientService) {}
/**
* 使 MPC 2-of-3
@ -73,22 +71,30 @@ export class MpcWalletService {
* 4. 使 MPC
* 5.
*/
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> {
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`);
async generateMpcWallet(
params: MpcWalletGenerationParams,
): Promise<MpcWalletGenerationResult> {
this.logger.log(
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
);
// Step 1: 生成 MPC 密钥
const keygenResult = await this.mpcClient.executeKeygen({
sessionId: this.mpcClient.generateSessionId(),
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,
requireDelegate: true,
});
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`);
this.logger.log(
`MPC keygen completed: publicKey=${keygenResult.publicKey}`,
);
// Step 2: 从公钥派生三条链的地址
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey);
const walletAddresses = await this.deriveChainAddresses(
keygenResult.publicKey,
);
// Step 3: 计算地址摘要
const addressDigest = this.computeAddressDigest(walletAddresses);
@ -99,7 +105,9 @@ export class MpcWalletService {
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: 构建钱包信息
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
@ -140,7 +148,9 @@ export class MpcWalletService {
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
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;
}
@ -155,10 +165,19 @@ export class MpcWalletService {
for (const v of [27, 28]) {
try {
const sig = ethers.Signature.from({ r, s, v });
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
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;
}
} catch {
@ -179,13 +198,18 @@ export class MpcWalletService {
* - BSC/KAVA: EVM (keccak256)
* - 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 { bech32 } = await import('bech32');
// MPC 公钥 (压缩格式33 bytes)
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;
@ -204,9 +228,14 @@ export class MpcWalletService {
// ===== Cosmos 地址派生 (DST) =====
// 地址 = 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 dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash));
const dstAddress = bech32.encode(
this.chainConfigs.DST.prefix,
bech32.toWords(ripemd160Hash),
);
return [
{ chainType: 'BSC', address: evmAddress },
@ -220,14 +249,18 @@ export class MpcWalletService {
*
* 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) =>
a.chainType.localeCompare(b.chainType),
);
// 拼接地址
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join('');
const concatenated = sortedAddresses
.map((a) => a.address.toLowerCase())
.join('');
// 计算 SHA256 摘要
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()}`;
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 aliyunConfig = smsConfig.aliyun || {};
this.signName = aliyunConfig.signName || 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';
this.signName =
aliyunConfig.signName ||
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() {
@ -35,8 +40,13 @@ export class SmsService implements OnModuleInit {
private async initClient(): Promise<void> {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
const accessKeySecret = this.configService.get<string>(
'ALIYUN_ACCESS_KEY_SECRET',
);
const endpoint = this.configService.get<string>(
'ALIYUN_SMS_ENDPOINT',
'dysmsapi.aliyuncs.com',
);
if (!accessKeyId || !accessKeySecret) {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
@ -64,15 +74,22 @@ export class SmsService implements OnModuleInit {
* @param code
* @returns
*/
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
async sendVerificationCode(
phoneNumber: string,
code: string,
): Promise<SmsSendResult> {
// 标准化手机号(去除 +86 前缀)
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`);
this.logger.log(
`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
// 开发环境或未启用时,使用模拟模式
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
this.logger.warn(
`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
return {
success: true,
requestId: 'mock-request-id',
@ -92,9 +109,12 @@ export class SmsService implements OnModuleInit {
const runtime = new $Util.RuntimeOptions({
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 result: SmsSendResult = {
@ -106,9 +126,13 @@ export class SmsService implements OnModuleInit {
};
if (result.success) {
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
this.logger.log(
`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
);
} else {
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`);
this.logger.error(
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
);
}
return result;
@ -148,7 +172,9 @@ export class SmsService implements OnModuleInit {
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
this.logger.warn(
`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
);
return {
success: true,
requestId: 'mock-request-id',
@ -167,9 +193,12 @@ export class SmsService implements OnModuleInit {
const runtime = new $Util.RuntimeOptions({
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;
return {
@ -207,19 +236,23 @@ export class SmsService implements OnModuleInit {
}
try {
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({
phoneNumber: this.normalizePhoneNumber(phoneNumber),
bizId,
sendDate,
pageSize: 10,
currentPage: 1,
});
const querySendDetailsRequest =
new $Dysmsapi20170525.QuerySendDetailsRequest({
phoneNumber: this.normalizePhoneNumber(phoneNumber),
bizId,
sendDate,
pageSize: 10,
currentPage: 1,
});
const runtime = new $Util.RuntimeOptions({
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;
} catch (error: any) {
@ -251,6 +284,10 @@ export class SmsService implements OnModuleInit {
if (phoneNumber.length < 7) {
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;
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 useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
const useSSL =
this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
const accessKey = this.configService.get<string>(
'MINIO_ACCESS_KEY',
'admin',
);
const secretKey = this.configService.get<string>(
'MINIO_SECRET_KEY',
'minio_secret_password',
);
this.bucketAvatars = this.configService.get<string>('MINIO_BUCKET_AVATARS', 'avatars');
this.publicUrl = this.configService.get<string>('MINIO_PUBLIC_URL', 'http://localhost:9000');
this.bucketAvatars = this.configService.get<string>(
'MINIO_BUCKET_AVATARS',
'avatars',
);
this.publicUrl = this.configService.get<string>(
'MINIO_PUBLIC_URL',
'http://localhost:9000',
);
this.client = new Minio.Client({
endPoint: endpoint,
@ -83,7 +99,9 @@ export class StorageService implements OnModuleInit {
this.logger.log(`Bucket exists: ${bucketName}`);
}
} catch (error) {
this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`);
this.logger.error(
`Failed to ensure bucket ${bucketName}: ${error.message}`,
);
// 不抛出异常允许服务启动MinIO可能暂时不可用
}
}
@ -156,7 +174,7 @@ export class StorageService implements OnModuleInit {
try {
const urlObj = new URL(url);
// 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) {
return pathParts.slice(1).join('/');
}
@ -184,7 +202,13 @@ export class StorageService implements OnModuleInit {
*
*/
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);
}

View File

@ -5,7 +5,12 @@
* 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 { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
@ -22,15 +27,17 @@ export interface WalletAddressCreatedPayload {
address: string;
}[];
// 恢复助记词相关
mnemonic?: string; // 12词助记词 (明文)
encryptedMnemonic?: string; // 加密的助记词
mnemonicHash?: string; // 助记词哈希
mnemonic?: string; // 12词助记词 (明文)
encryptedMnemonic?: string; // 加密的助记词
mnemonicHash?: string; // 助记词哈希
}
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
@Injectable()
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy {
export class BlockchainEventConsumerService
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(BlockchainEventConsumerService.name);
private kafka: Kafka;
private consumer: Consumer;
@ -41,15 +48,20 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const brokers = this.configService
.get<string>('KAFKA_BROKERS')
?.split(',') || ['localhost:9092'];
const clientId =
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const groupId = 'identity-service-blockchain-events';
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`);
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 小时
this.kafka = new Kafka({
@ -57,10 +69,10 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
brokers,
logLevel: logLevel.WARN,
retry: {
initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子
initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子
restartOnFailure: async () => true,
},
});
@ -75,16 +87,26 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
await this.consumer.connect();
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
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
await this.consumer.subscribe({
topics: Object.values(BLOCKCHAIN_TOPICS),
fromBeginning: false,
});
this.logger.log(
`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
);
// Start consuming
await this.startConsuming();
} 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
*/
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void {
onWalletAddressCreated(
handler: BlockchainEventHandler<WalletAddressCreatedPayload>,
): void {
this.walletAddressCreatedHandler = handler;
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
}
private async startConsuming(): Promise<void> {
await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
eachMessage: async ({
topic,
partition,
message,
}: EachMessagePayload) => {
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 {
const value = message.value?.toString();
@ -116,33 +146,53 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
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 payload = parsed.payload || parsed;
const eventType = parsed.eventType || 'unknown';
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
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] userId: ${payload.userId}`);
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`);
this.logger.log(
`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`,
);
this.logger.log(
`[HANDLE] addresses count: ${payload.addresses?.length}`,
);
if (this.walletAddressCreatedHandler) {
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload);
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`);
await this.walletAddressCreatedHandler(
payload as WalletAddressCreatedPayload,
);
this.logger.log(
`[HANDLE] WalletAddressCreated handler completed successfully`,
);
} else {
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`);
this.logger.warn(
`[HANDLE] No handler registered for WalletAddressCreated`,
);
}
} else {
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
}
} 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
// This ensures messages are not marked as consumed until successfully processed
throw error;

View File

@ -65,7 +65,9 @@ export class DeadLetterService {
const [total, pending, processed, byTopic] = await Promise.all([
this.prisma.deadLetterEvent.count(),
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({
by: ['topic'],
_count: true,

View File

@ -94,7 +94,9 @@ export class EventConsumerController {
try {
await this.processKYCSubmitted(message.payload);
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`);
this.logger.log(
`Successfully processed KYCSubmitted: ${message.eventId}`,
);
} catch (error) {
this.logger.error(
`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 { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
import { DomainEvent } from '@/domain/events';
@ -48,8 +53,13 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private producer: Producer;
constructor(private readonly configService: ConfigService) {
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
const brokers = this.configService
.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] ClientId: ${clientId}`);
@ -77,7 +87,10 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
async publish(event: DomainEvent): 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') {
// 直接发布到指定 topic (用于重试场景)
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 {
// 从领域事件发布
const event = eventOrTopic;
const topic = this.getTopicForEvent(event);
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.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.
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
@ -32,7 +37,7 @@ export interface KeygenCompletedPayload {
threshold: string;
extraPayload?: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
username: string;
delegateShare?: {
partyId: string;
@ -85,15 +90,20 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const brokers = this.configService
.get<string>('KAFKA_BROKERS')
?.split(',') || ['localhost:9092'];
const clientId =
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const groupId = 'identity-service-mpc-events';
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`);
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 小时
this.kafka = new Kafka({
@ -101,10 +111,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
brokers,
logLevel: logLevel.WARN,
retry: {
initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子
initialRetryTime: 1000, // 1 秒
maxRetryTime: 300000, // 最大 5 分钟
retries: 15, // 最多 15 次
multiplier: 2, // 指数退避因子
restartOnFailure: async () => true,
},
});
@ -119,16 +129,26 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
await this.consumer.connect();
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
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
await this.consumer.subscribe({
topics: Object.values(MPC_TOPICS),
fromBeginning: false,
});
this.logger.log(
`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`,
);
// Start consuming
await this.startConsuming();
} 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> {
await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
eachMessage: async ({
topic,
partition,
message,
}: EachMessagePayload) => {
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 {
const value = message.value?.toString();
@ -180,55 +206,83 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
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 payload = parsed.payload || parsed;
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`);
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
this.logger.log(
`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`,
);
this.logger.log(
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
);
switch (topic) {
case MPC_TOPICS.KEYGEN_STARTED:
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
if (this.keygenStartedHandler) {
await this.keygenStartedHandler(payload as KeygenStartedPayload);
await this.keygenStartedHandler(
payload as KeygenStartedPayload,
);
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for KeygenStarted`);
this.logger.warn(
`[HANDLE] No handler registered for KeygenStarted`,
);
}
break;
case MPC_TOPICS.KEYGEN_COMPLETED:
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) {
await this.keygenCompletedHandler(payload as KeygenCompletedPayload);
await this.keygenCompletedHandler(
payload as KeygenCompletedPayload,
);
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`);
this.logger.warn(
`[HANDLE] No handler registered for KeygenCompleted`,
);
}
break;
case MPC_TOPICS.SIGNING_COMPLETED:
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
if (this.signingCompletedHandler) {
await this.signingCompletedHandler(payload as SigningCompletedPayload);
await this.signingCompletedHandler(
payload as SigningCompletedPayload,
);
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for SigningCompleted`);
this.logger.warn(
`[HANDLE] No handler registered for SigningCompleted`,
);
}
break;
case MPC_TOPICS.SESSION_FAILED:
this.logger.log(`[HANDLE] Processing SessionFailed event`);
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`);
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`);
this.logger.log(
`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`,
);
this.logger.log(
`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`,
);
if (this.sessionFailedHandler) {
await this.sessionFailedHandler(payload as SessionFailedPayload);
await this.sessionFailedHandler(
payload as SessionFailedPayload,
);
this.logger.log(`[HANDLE] SessionFailed handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`);
this.logger.warn(
`[HANDLE] No handler registered for SessionFailed`,
);
}
break;
@ -236,7 +290,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
}
} 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
// This ensures messages are not marked as consumed until successfully processed
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 { 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方案 - )
@ -38,14 +46,31 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly outboxRepository: OutboxRepository,
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.cleanupIntervalMs = this.configService.get<number>('OUTBOX_CLEANUP_INTERVAL_MS', 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分钟
this.cleanupIntervalMs = this.configService.get<number>(
'OUTBOX_CLEANUP_INTERVAL_MS',
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 clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
const brokers = this.configService
.get<string>('KAFKA_BROKERS', 'localhost:9092')
.split(',');
const clientId = this.configService.get<string>(
'KAFKA_CLIENT_ID',
'identity-service',
);
this.kafka = new Kafka({
clientId: `${clientId}-outbox`,
@ -56,8 +81,8 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
this.logger.log(
`[OUTBOX] OutboxPublisher (B方案) configured: ` +
`pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` +
`confirmationTimeout=${this.confirmationTimeoutMinutes}min`,
`pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` +
`confirmationTimeout=${this.confirmationTimeoutMinutes}min`,
);
}
@ -70,7 +95,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
this.start();
} catch (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方案核心
this.timeoutCheckInterval = setInterval(() => {
this.checkConfirmationTimeouts().catch((err) => {
this.logger.error('[OUTBOX] Error checking confirmation timeouts:', err);
this.logger.error(
'[OUTBOX] Error checking confirmation timeouts:',
err,
);
});
}, this.timeoutCheckIntervalMs);
@ -114,7 +144,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
});
}, 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 {
// 1. 获取待发布事件
const pendingEvents = await this.outboxRepository.findPendingEvents(this.batchSize);
const pendingEvents = await this.outboxRepository.findPendingEvents(
this.batchSize,
);
// 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];
@ -164,7 +200,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
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. 逐个发布
for (const event of allEvents) {
@ -183,7 +221,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
*/
private async publishEvent(event: OutboxEvent): Promise<void> {
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 用于确认
const payload = {
@ -213,8 +253,11 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
`[OUTBOX] → Event ${event.id} sent to ${event.topic} (awaiting consumer confirmation)`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[OUTBOX] ✗ Failed to publish event ${event.id}: ${errorMessage}`);
const 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);
@ -252,7 +295,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
);
}
} 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> {
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);
}

View File

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

View File

@ -26,11 +26,15 @@ export function toMpcSignatureString(entity: WalletAddressEntity): string {
*
* 应用: 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)
return {
r: signature.slice(0, 64) || '',
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 { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
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 { toMpcSignatureString } from '../entities/wallet-address.entity';
@ -27,7 +34,7 @@ export class UserAccountMapper {
address: w.address,
publicKey: w.publicKey,
addressDigest: w.addressDigest,
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
status: w.status as AddressStatus,
boundAt: w.boundAt,
}),
@ -45,12 +52,12 @@ export class UserAccountMapper {
return UserAccount.reconstruct({
userId: entity.userId.toString(),
accountSequence: entity.accountSequence, // 现在是字符串类型
accountSequence: entity.accountSequence, // 现在是字符串类型
devices,
phoneNumber: entity.phoneNumber,
nickname: entity.nickname,
avatarUrl: entity.avatarUrl,
inviterSequence: entity.inviterSequence, // 现在是字符串类型
inviterSequence: entity.inviterSequence, // 现在是字符串类型
referralCode: entity.referralCode,
walletAddresses: wallets,
kycInfo,

View File

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

View File

@ -3,10 +3,10 @@ import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
export enum OutboxStatus {
PENDING = 'PENDING', // 待发送
SENT = 'SENT', // 已发送到 Kafka等待消费方确认
CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功
FAILED = 'FAILED', // 发送失败,等待重试
PENDING = 'PENDING', // 待发送
SENT = 'SENT', // 已发送到 Kafka等待消费方确认
CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功
FAILED = 'FAILED', // 发送失败,等待重试
}
export interface OutboxEventData {
@ -44,7 +44,9 @@ export class OutboxRepository {
): Promise<void> {
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({
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) {
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;
}
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;
}
@ -190,7 +198,10 @@ export class OutboxRepository {
*
* 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();
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) {
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 {
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;
}> {
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.CONFIRMED } }),
this.prisma.outboxEvent.count({
where: { status: OutboxStatus.CONFIRMED },
}),
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }),
]);

View File

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

View File

@ -39,7 +39,9 @@ async function bootstrap() {
SwaggerModule.setup('api/docs', app, document);
// 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';
app.connectMicroservice<MicroserviceOptions>({

View File

@ -2,7 +2,10 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { CurrentUserData } from '@/shared/guards/jwt-auth.guard';
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 user = request.user as CurrentUserData;
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';
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 { DomainError } from '@/shared/exceptions/domain.exception';

View File

@ -1,11 +1,22 @@
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger,
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception';
import {
DomainError,
ApplicationError,
} from '@/shared/exceptions/domain.exception';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
@ -72,8 +83,14 @@ export interface ApiResponse<T> {
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
export class TransformInterceptor<T> implements NestInterceptor<
T,
ApiResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
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 { JwtService } from '@nestjs/jwt';
import { UnauthorizedException } from '@/shared/exceptions/domain.exception';
export interface JwtPayload {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string;
type: 'access' | 'refresh';
}
export interface CurrentUserData {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
accountSequence: string; // 格式: D + YYMMDD + 5位序号
deviceId: string;
}
@ -20,7 +26,10 @@ export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
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 user = request.user as CurrentUserData;
return data ? user?.[data] : user;
@ -48,7 +57,8 @@ export class JwtAuthGuard implements CanActivate {
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
if (payload.type !== 'access') throw new UnauthorizedException('无效的令牌类型');
if (payload.type !== 'access')
throw new UnauthorizedException('无效的令牌类型');
request.user = {
userId: payload.userId,
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 { map } from 'rxjs/operators';
@ -9,8 +14,14 @@ export interface ApiResponse<T> {
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
export class TransformInterceptor<T> implements NestInterceptor<
T,
ApiResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,

View File

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

View File

@ -118,15 +118,18 @@ const DECORATIONS = [
*/
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 face = FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)];
const face =
FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)];
// 随机选择装饰 (50%概率有装饰)
const decoration = Math.random() > 0.5
? DECORATIONS[Math.floor(Math.random() * (DECORATIONS.length - 1))]
: DECORATIONS[DECORATIONS.length - 1];
const decoration =
Math.random() > 0.5
? 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">
<rect width="100" height="100" fill="${palette.bg}"/>
@ -140,7 +143,10 @@ export function generateRandomAvatarSvg(): string {
*
* @param accountSequence (格式: D + YYMMDD + 5)
*/
export function generateIdentity(accountSequence: string): { username: string; avatarSvg: string } {
export function generateIdentity(accountSequence: string): {
username: string;
avatarSvg: string;
} {
return {
username: generateUsername(accountSequence),
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('dst');
expect(response.body.data.walletAddresses).toHaveProperty('bsc');
expect(response.body.data.walletAddresses.kava).toMatch(/^kava1[a-z0-9]{38}$/);
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}$/);
expect(response.body.data.walletAddresses.kava).toMatch(
/^kava1[a-z0-9]{38}$/,
);
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 () => {
@ -182,7 +188,9 @@ describe('Identity Service E2E Tests', () => {
.expect(200);
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('测试地址');
});
});
@ -283,7 +291,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status !== 201) {
console.log(`Device limit test failed at iteration ${i}:`, {
status: response.status,
body: response.body
body: response.body,
});
}
@ -328,8 +336,11 @@ describe('Identity Service E2E Tests', () => {
console.log('Auto-login failed:', {
status: response.status,
body: response.body,
sentData: { refreshToken: refreshToken?.substring(0, 20) + '...', deviceId: validDeviceId },
availableDevices: devicesResponse.body.data
sentData: {
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:', {
expectedStatus: 401,
actualStatus: response.status,
body: response.body
body: response.body,
});
}
@ -463,7 +474,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status !== 201) {
console.log('Mnemonic recovery failed:', {
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')
.send({
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()}`,
deviceName: '错误设备',
})
@ -501,7 +513,7 @@ describe('Identity Service E2E Tests', () => {
console.log('Mismatch account sequence test failed:', {
expectedStatus: 404,
actualStatus: response.status,
body: response.body
body: response.body,
});
}
@ -545,7 +557,7 @@ describe('Identity Service E2E Tests', () => {
if (response.status === 400) {
console.log(`Phone format test failed for ${phone}:`, {
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.walletAddresses.bsc).not.toBe(response2.body.walletAddresses.bsc);
expect(response1.body.accountSequence).not.toBe(
response2.body.accountSequence,
);
expect(response1.body.walletAddresses.bsc).not.toBe(
response2.body.walletAddresses.bsc,
);
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)
///
///

View File

@ -96,7 +96,7 @@ class _GuidePageState extends ConsumerState<GuidePage> {
GuidePageData(
imagePath: 'assets/images/guide_5.jpg',
title: '欢迎加入',
subtitle: '创建账号前的最后一步 · 请选择是否有推荐人',
subtitle: '注册账号前的最后一步 · 请选择是否有推荐人',
),
];
@ -516,7 +516,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
SizedBox(height: 12.h),
//
Text(
'创建账号前的最后一步 · 请输入推荐码',
'注册账号前的最后一步 · 请输入推荐码',
style: TextStyle(
fontSize: 14.sp,
height: 1.43,
@ -549,7 +549,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'下一步 (创建账号)',
'下一步 (注册账号)',
style: TextStyle(
fontSize: 16.sp,
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:go_router/go_router.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 '../providers/auth_provider.dart';
@ -91,26 +88,29 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone');
// API AccountService
// TODO: + API
// final response = await accountService.loginWithPassword(phone, password);
// AccountService
final accountService = ref.read(accountServiceProvider);
//
throw Exception('手机号+密码登录 API 尚未实现');
// ID
final deviceId = await accountService.getDeviceId();
debugPrint('[PhoneLoginPage] 获取设备ID成功');
//
// 1. access token refresh token
// 2. userId, accountSequence, referralCode
// 3.
// 4.
// API
final response = await accountService.loginWithPassword(
phoneNumber: phone,
password: password,
deviceId: deviceId,
);
// if (mounted) {
// //
// await ref.read(authProvider.notifier).checkAuthStatus();
//
// //
// context.go(RoutePaths.ranking);
// }
debugPrint('[PhoneLoginPage] 登录成功 - accountSequence: ${response.accountSequence}');
if (mounted) {
//
await ref.read(authProvider.notifier).checkAuthStatus();
//
context.go(RoutePaths.ranking);
}
} catch (e) {
debugPrint('[PhoneLoginPage] 登录失败: $e');
setState(() {