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:
parent
65f3e75f59
commit
b4c4239593
|
|
@ -7,6 +7,11 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
controllers: [UserAccountController, AuthController, ReferralsController, TotpController],
|
controllers: [
|
||||||
|
UserAccountController,
|
||||||
|
AuthController,
|
||||||
|
ReferralsController,
|
||||||
|
TotpController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
@ -23,7 +29,9 @@ export class AuthController {
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiOperation({ summary: 'Token刷新' })
|
@ApiOperation({ summary: 'Token刷新' })
|
||||||
async refresh(@Body() dto: AutoLoginDto) {
|
async refresh(@Body() dto: AutoLoginDto) {
|
||||||
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
|
return this.userService.autoLogin(
|
||||||
|
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
|
@ -46,12 +54,17 @@ export class AuthController {
|
||||||
|
|
||||||
// 检查账户状态
|
// 检查账户状态
|
||||||
if (admin.status !== 'ACTIVE') {
|
if (admin.status !== 'ACTIVE') {
|
||||||
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`);
|
this.logger.warn(
|
||||||
|
`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密码 (使用 bcrypt)
|
// 验证密码 (使用 bcrypt)
|
||||||
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
|
const isPasswordValid = await bcrypt.compare(
|
||||||
|
dto.password,
|
||||||
|
admin.passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
|
||||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiResponse,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
|
import {
|
||||||
|
JwtAuthGuard,
|
||||||
|
Public,
|
||||||
|
CurrentUser,
|
||||||
|
CurrentUserData,
|
||||||
|
} from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
ValidateReferralCodeQuery,
|
||||||
|
GetReferralStatsQuery,
|
||||||
|
GenerateReferralLinkCommand,
|
||||||
} from '@/application/commands';
|
} from '@/application/commands';
|
||||||
import {
|
import {
|
||||||
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
|
GenerateReferralLinkDto,
|
||||||
ReferralLinkResponseDto, ReferralStatsResponseDto,
|
MeResponseDto,
|
||||||
|
ReferralValidationResponseDto,
|
||||||
|
ReferralLinkResponseDto,
|
||||||
|
ReferralStatsResponseDto,
|
||||||
} from '@/api/dto';
|
} from '@/api/dto';
|
||||||
|
|
||||||
@ApiTags('Referrals')
|
@ApiTags('Referrals')
|
||||||
|
|
@ -21,7 +37,10 @@ export class ReferralsController {
|
||||||
*/
|
*/
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
|
@ApiOperation({
|
||||||
|
summary: '获取当前登录用户信息',
|
||||||
|
description: '返回用户基本信息、推荐码和推荐链接',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: MeResponseDto })
|
@ApiResponse({ status: 200, type: MeResponseDto })
|
||||||
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
||||||
return this.userService.getMe(user.userId);
|
return this.userService.getMe(user.userId);
|
||||||
|
|
@ -32,11 +51,18 @@ export class ReferralsController {
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Get('referrals/validate')
|
@Get('referrals/validate')
|
||||||
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
|
@ApiOperation({
|
||||||
|
summary: '校验推荐码',
|
||||||
|
description: '创建账号时校验推荐码是否合法',
|
||||||
|
})
|
||||||
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
||||||
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
||||||
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
|
async validateReferralCode(
|
||||||
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
|
@Query('code') code: string,
|
||||||
|
): Promise<ReferralValidationResponseDto> {
|
||||||
|
return this.userService.validateReferralCode(
|
||||||
|
new ValidateReferralCodeQuery(code),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,7 +70,10 @@ export class ReferralsController {
|
||||||
*/
|
*/
|
||||||
@Post('referrals/links')
|
@Post('referrals/links')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
|
@ApiOperation({
|
||||||
|
summary: '生成推荐链接',
|
||||||
|
description: '为当前登录用户生成短链/渠道链接',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
||||||
async generateReferralLink(
|
async generateReferralLink(
|
||||||
@CurrentUser() user: CurrentUserData,
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
|
@ -60,9 +89,16 @@ export class ReferralsController {
|
||||||
*/
|
*/
|
||||||
@Get('referrals/stats')
|
@Get('referrals/stats')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
|
@ApiOperation({
|
||||||
|
summary: '查询邀请统计',
|
||||||
|
description: '查询登录用户的邀请记录和统计数据',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
||||||
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
|
async getReferralStats(
|
||||||
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
): Promise<ReferralStatsResponseDto> {
|
||||||
|
return this.userService.getReferralStats(
|
||||||
|
new GetReferralStatsQuery(user.userId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { TotpService } from '@/application/services/totp.service';
|
import { TotpService } from '@/application/services/totp.service';
|
||||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -36,21 +41,34 @@ export class TotpController {
|
||||||
constructor(private readonly totpService: TotpService) {}
|
constructor(private readonly totpService: TotpService) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
@ApiOperation({ summary: '获取 TOTP 状态', description: '查询当前用户的 TOTP 启用状态' })
|
@ApiOperation({
|
||||||
|
summary: '获取 TOTP 状态',
|
||||||
|
description: '查询当前用户的 TOTP 启用状态',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
|
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
|
||||||
async getStatus(@CurrentUser() user: CurrentUserPayload): Promise<TotpStatusResponseDto> {
|
async getStatus(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
): Promise<TotpStatusResponseDto> {
|
||||||
return this.totpService.getTotpStatus(BigInt(user.userId));
|
return this.totpService.getTotpStatus(BigInt(user.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('setup')
|
@Post('setup')
|
||||||
@ApiOperation({ summary: '设置 TOTP', description: '生成 TOTP 密钥,返回二维码和手动输入密钥' })
|
@ApiOperation({
|
||||||
|
summary: '设置 TOTP',
|
||||||
|
description: '生成 TOTP 密钥,返回二维码和手动输入密钥',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
|
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
|
||||||
async setup(@CurrentUser() user: CurrentUserPayload): Promise<SetupTotpResponseDto> {
|
async setup(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
): Promise<SetupTotpResponseDto> {
|
||||||
return this.totpService.setupTotp(BigInt(user.userId));
|
return this.totpService.setupTotp(BigInt(user.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('enable')
|
@Post('enable')
|
||||||
@ApiOperation({ summary: '启用 TOTP', description: '验证码正确后启用 TOTP 二次验证' })
|
@ApiOperation({
|
||||||
|
summary: '启用 TOTP',
|
||||||
|
description: '验证码正确后启用 TOTP 二次验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
|
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
|
||||||
async enable(
|
async enable(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
|
@ -61,7 +79,10 @@ export class TotpController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('disable')
|
@Post('disable')
|
||||||
@ApiOperation({ summary: '禁用 TOTP', description: '验证码正确后禁用 TOTP 二次验证' })
|
@ApiOperation({
|
||||||
|
summary: '禁用 TOTP',
|
||||||
|
description: '验证码正确后禁用 TOTP 二次验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
|
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
|
||||||
async disable(
|
async disable(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
|
@ -72,13 +93,19 @@ export class TotpController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('verify')
|
@Post('verify')
|
||||||
@ApiOperation({ summary: '验证 TOTP', description: '验证 TOTP 验证码是否正确' })
|
@ApiOperation({
|
||||||
|
summary: '验证 TOTP',
|
||||||
|
description: '验证 TOTP 验证码是否正确',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
async verify(
|
async verify(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Body() dto: VerifyTotpDto,
|
@Body() dto: VerifyTotpDto,
|
||||||
): Promise<{ valid: boolean }> {
|
): Promise<{ valid: boolean }> {
|
||||||
const valid = await this.totpService.verifyTotp(BigInt(user.userId), dto.code);
|
const valid = await this.totpService.verifyTotp(
|
||||||
|
BigInt(user.userId),
|
||||||
|
dto.code,
|
||||||
|
);
|
||||||
return { valid };
|
return { valid };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,82 @@
|
||||||
import {
|
import {
|
||||||
Controller, Post, Get, Put, Body, Param, UseGuards, Headers,
|
Controller,
|
||||||
UseInterceptors, UploadedFile, BadRequestException,
|
Post,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Headers,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiResponse,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiBody,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
JwtAuthGuard,
|
||||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
Public,
|
||||||
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
|
CurrentUser,
|
||||||
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
|
CurrentUserData,
|
||||||
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand,
|
} from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
AutoCreateAccountCommand,
|
||||||
|
RecoverByMnemonicCommand,
|
||||||
|
RecoverByPhoneCommand,
|
||||||
|
AutoLoginCommand,
|
||||||
|
RegisterCommand,
|
||||||
|
LoginCommand,
|
||||||
|
BindPhoneNumberCommand,
|
||||||
|
UpdateProfileCommand,
|
||||||
|
SubmitKYCCommand,
|
||||||
|
RemoveDeviceCommand,
|
||||||
|
SendSmsCodeCommand,
|
||||||
|
GetMyProfileQuery,
|
||||||
|
GetMyDevicesQuery,
|
||||||
|
GetUserByReferralCodeQuery,
|
||||||
|
GetWalletStatusQuery,
|
||||||
|
MarkMnemonicBackedUpCommand,
|
||||||
|
VerifySmsCodeCommand,
|
||||||
|
SetPasswordCommand,
|
||||||
} from '@/application/commands';
|
} from '@/application/commands';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
AutoCreateAccountDto,
|
||||||
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
|
RecoverByMnemonicDto,
|
||||||
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto,
|
RecoverByPhoneDto,
|
||||||
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto,
|
AutoLoginDto,
|
||||||
GenerateBackupCodesDto, RecoverByBackupCodeDto,
|
SendSmsCodeDto,
|
||||||
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
|
RegisterDto,
|
||||||
UserProfileResponseDto, DeviceResponseDto,
|
LoginDto,
|
||||||
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
|
BindPhoneDto,
|
||||||
VerifySmsCodeDto, SetPasswordDto,
|
UpdateProfileDto,
|
||||||
|
BindWalletDto,
|
||||||
|
SubmitKYCDto,
|
||||||
|
RemoveDeviceDto,
|
||||||
|
RevokeMnemonicDto,
|
||||||
|
FreezeAccountDto,
|
||||||
|
UnfreezeAccountDto,
|
||||||
|
RequestKeyRotationDto,
|
||||||
|
GenerateBackupCodesDto,
|
||||||
|
RecoverByBackupCodeDto,
|
||||||
|
AutoCreateAccountResponseDto,
|
||||||
|
RecoverAccountResponseDto,
|
||||||
|
LoginResponseDto,
|
||||||
|
UserProfileResponseDto,
|
||||||
|
DeviceResponseDto,
|
||||||
|
WalletStatusReadyResponseDto,
|
||||||
|
WalletStatusGeneratingResponseDto,
|
||||||
|
VerifySmsCodeDto,
|
||||||
|
SetPasswordDto,
|
||||||
|
LoginWithPasswordDto,
|
||||||
} from '@/api/dto';
|
} from '@/api/dto';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
|
|
@ -42,7 +95,9 @@ export class UserAccountController {
|
||||||
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
||||||
return this.userService.autoCreateAccount(
|
return this.userService.autoCreateAccount(
|
||||||
new AutoCreateAccountCommand(
|
new AutoCreateAccountCommand(
|
||||||
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
|
dto.deviceId,
|
||||||
|
dto.deviceName,
|
||||||
|
dto.inviterReferralCode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +109,10 @@ export class UserAccountController {
|
||||||
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
||||||
return this.userService.recoverByMnemonic(
|
return this.userService.recoverByMnemonic(
|
||||||
new RecoverByMnemonicCommand(
|
new RecoverByMnemonicCommand(
|
||||||
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName,
|
dto.accountSequence,
|
||||||
|
dto.mnemonic,
|
||||||
|
dto.newDeviceId,
|
||||||
|
dto.deviceName,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -66,8 +124,11 @@ export class UserAccountController {
|
||||||
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
||||||
return this.userService.recoverByPhone(
|
return this.userService.recoverByPhone(
|
||||||
new RecoverByPhoneCommand(
|
new RecoverByPhoneCommand(
|
||||||
dto.accountSequence, dto.phoneNumber, dto.smsCode,
|
dto.accountSequence,
|
||||||
dto.newDeviceId, dto.deviceName,
|
dto.phoneNumber,
|
||||||
|
dto.smsCode,
|
||||||
|
dto.newDeviceId,
|
||||||
|
dto.deviceName,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -86,17 +147,26 @@ export class UserAccountController {
|
||||||
@Post('send-sms-code')
|
@Post('send-sms-code')
|
||||||
@ApiOperation({ summary: '发送短信验证码' })
|
@ApiOperation({ summary: '发送短信验证码' })
|
||||||
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||||
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type));
|
await this.userService.sendSmsCode(
|
||||||
|
new SendSmsCodeCommand(dto.phoneNumber, dto.type),
|
||||||
|
);
|
||||||
return { message: '验证码已发送' };
|
return { message: '验证码已发送' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('verify-sms-code')
|
@Post('verify-sms-code')
|
||||||
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' })
|
@ApiOperation({
|
||||||
|
summary: '验证短信验证码',
|
||||||
|
description: '仅验证验证码是否正确,不进行登录或注册',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证成功' })
|
@ApiResponse({ status: 200, description: '验证成功' })
|
||||||
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
||||||
await this.userService.verifySmsCode(
|
await this.userService.verifySmsCode(
|
||||||
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'),
|
new VerifySmsCodeCommand(
|
||||||
|
dto.phoneNumber,
|
||||||
|
dto.smsCode,
|
||||||
|
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return { message: '验证成功' };
|
return { message: '验证成功' };
|
||||||
}
|
}
|
||||||
|
|
@ -108,15 +178,18 @@ export class UserAccountController {
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.userService.register(
|
return this.userService.register(
|
||||||
new RegisterCommand(
|
new RegisterCommand(
|
||||||
dto.phoneNumber, dto.smsCode, dto.deviceId,
|
dto.phoneNumber,
|
||||||
dto.deviceName, dto.inviterReferralCode,
|
dto.smsCode,
|
||||||
|
dto.deviceId,
|
||||||
|
dto.deviceName,
|
||||||
|
dto.inviterReferralCode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: '用户登录(手机号)' })
|
@ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
async login(@Body() dto: LoginDto) {
|
async login(@Body() dto: LoginDto) {
|
||||||
return this.userService.login(
|
return this.userService.login(
|
||||||
|
|
@ -124,10 +197,28 @@ export class UserAccountController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login-with-password')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '用户登录(手机号+密码)',
|
||||||
|
description: '用于账号恢复,使用手机号和密码登录',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
|
async loginWithPassword(@Body() dto: LoginWithPasswordDto) {
|
||||||
|
return this.userService.loginWithPassword(
|
||||||
|
dto.phoneNumber,
|
||||||
|
dto.password,
|
||||||
|
dto.deviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bind-phone')
|
@Post('bind-phone')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '绑定手机号' })
|
@ApiOperation({ summary: '绑定手机号' })
|
||||||
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
|
async bindPhone(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: BindPhoneDto,
|
||||||
|
) {
|
||||||
await this.userService.bindPhoneNumber(
|
await this.userService.bindPhoneNumber(
|
||||||
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
||||||
);
|
);
|
||||||
|
|
@ -136,9 +227,15 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('set-password')
|
@Post('set-password')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' })
|
@ApiOperation({
|
||||||
|
summary: '设置登录密码',
|
||||||
|
description: '首次设置或修改登录密码',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '密码设置成功' })
|
@ApiResponse({ status: 200, description: '密码设置成功' })
|
||||||
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) {
|
async setPassword(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: SetPasswordDto,
|
||||||
|
) {
|
||||||
await this.userService.setPassword(
|
await this.userService.setPassword(
|
||||||
new SetPasswordCommand(user.userId, dto.password),
|
new SetPasswordCommand(user.userId, dto.password),
|
||||||
);
|
);
|
||||||
|
|
@ -156,7 +253,10 @@ export class UserAccountController {
|
||||||
@Put('update-profile')
|
@Put('update-profile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '更新用户资料' })
|
@ApiOperation({ summary: '更新用户资料' })
|
||||||
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
|
async updateProfile(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: UpdateProfileDto,
|
||||||
|
) {
|
||||||
await this.userService.updateProfile(
|
await this.userService.updateProfile(
|
||||||
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
||||||
);
|
);
|
||||||
|
|
@ -166,11 +266,17 @@ export class UserAccountController {
|
||||||
@Post('submit-kyc')
|
@Post('submit-kyc')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '提交KYC认证' })
|
@ApiOperation({ summary: '提交KYC认证' })
|
||||||
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
|
async submitKYC(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: SubmitKYCDto,
|
||||||
|
) {
|
||||||
await this.userService.submitKYC(
|
await this.userService.submitKYC(
|
||||||
new SubmitKYCCommand(
|
new SubmitKYCCommand(
|
||||||
user.userId, dto.realName, dto.idCardNumber,
|
user.userId,
|
||||||
dto.idCardFrontUrl, dto.idCardBackUrl,
|
dto.realName,
|
||||||
|
dto.idCardNumber,
|
||||||
|
dto.idCardFrontUrl,
|
||||||
|
dto.idCardBackUrl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return { message: '提交成功' };
|
return { message: '提交成功' };
|
||||||
|
|
@ -181,13 +287,18 @@ export class UserAccountController {
|
||||||
@ApiOperation({ summary: '查看我的设备列表' })
|
@ApiOperation({ summary: '查看我的设备列表' })
|
||||||
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
||||||
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
||||||
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId));
|
return this.userService.getMyDevices(
|
||||||
|
new GetMyDevicesQuery(user.userId, user.deviceId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('remove-device')
|
@Post('remove-device')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '移除设备' })
|
@ApiOperation({ summary: '移除设备' })
|
||||||
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
|
async removeDevice(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: RemoveDeviceDto,
|
||||||
|
) {
|
||||||
await this.userService.removeDevice(
|
await this.userService.removeDevice(
|
||||||
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
||||||
);
|
);
|
||||||
|
|
@ -198,14 +309,24 @@ export class UserAccountController {
|
||||||
@Get('by-referral-code/:code')
|
@Get('by-referral-code/:code')
|
||||||
@ApiOperation({ summary: '根据推荐码查询用户' })
|
@ApiOperation({ summary: '根据推荐码查询用户' })
|
||||||
async getByReferralCode(@Param('code') code: string) {
|
async getByReferralCode(@Param('code') code: string) {
|
||||||
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
|
return this.userService.getUserByReferralCode(
|
||||||
|
new GetUserByReferralCodeQuery(code),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('wallet')
|
@Get('wallet')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
||||||
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto })
|
status: 200,
|
||||||
|
description: '钱包已就绪',
|
||||||
|
type: WalletStatusReadyResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 202,
|
||||||
|
description: '钱包生成中',
|
||||||
|
type: WalletStatusGeneratingResponseDto,
|
||||||
|
})
|
||||||
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
||||||
return this.userService.getWalletStatus(
|
return this.userService.getWalletStatus(
|
||||||
new GetWalletStatusQuery(user.accountSequence),
|
new GetWalletStatusQuery(user.accountSequence),
|
||||||
|
|
@ -214,7 +335,10 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('wallet/retry')
|
@Post('wallet/retry')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' })
|
@ApiOperation({
|
||||||
|
summary: '手动重试钱包生成',
|
||||||
|
description: '当钱包生成失败或超时时,用户可手动触发重试',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
||||||
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
||||||
await this.userService.retryWalletGeneration(user.userId);
|
await this.userService.retryWalletGeneration(user.userId);
|
||||||
|
|
@ -234,25 +358,43 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('mnemonic/revoke')
|
@Post('mnemonic/revoke')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' })
|
@ApiOperation({
|
||||||
|
summary: '挂失助记词',
|
||||||
|
description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '挂失结果' })
|
@ApiResponse({ status: 200, description: '挂失结果' })
|
||||||
async revokeMnemonic(@CurrentUser() user: CurrentUserData, @Body() dto: RevokeMnemonicDto) {
|
async revokeMnemonic(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: RevokeMnemonicDto,
|
||||||
|
) {
|
||||||
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('freeze')
|
@Post('freeze')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' })
|
@ApiOperation({
|
||||||
|
summary: '冻结账户',
|
||||||
|
description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '冻结结果' })
|
@ApiResponse({ status: 200, description: '冻结结果' })
|
||||||
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) {
|
async freezeAccount(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: FreezeAccountDto,
|
||||||
|
) {
|
||||||
return this.userService.freezeAccount(user.userId, dto.reason);
|
return this.userService.freezeAccount(user.userId, dto.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('unfreeze')
|
@Post('unfreeze')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' })
|
@ApiOperation({
|
||||||
|
summary: '解冻账户',
|
||||||
|
description: '验证身份后解冻账户,支持助记词或手机号验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '解冻结果' })
|
@ApiResponse({ status: 200, description: '解冻结果' })
|
||||||
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) {
|
async unfreezeAccount(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: UnfreezeAccountDto,
|
||||||
|
) {
|
||||||
return this.userService.unfreezeAccount({
|
return this.userService.unfreezeAccount({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
verifyMethod: dto.verifyMethod,
|
verifyMethod: dto.verifyMethod,
|
||||||
|
|
@ -264,9 +406,15 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('key-rotation/request')
|
@Post('key-rotation/request')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' })
|
@ApiOperation({
|
||||||
|
summary: '请求密钥轮换',
|
||||||
|
description: '验证当前助记词后,请求轮换 MPC 密钥对',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
||||||
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) {
|
async requestKeyRotation(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: RequestKeyRotationDto,
|
||||||
|
) {
|
||||||
return this.userService.requestKeyRotation({
|
return this.userService.requestKeyRotation({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
currentMnemonic: dto.currentMnemonic,
|
currentMnemonic: dto.currentMnemonic,
|
||||||
|
|
@ -276,9 +424,15 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('backup-codes/generate')
|
@Post('backup-codes/generate')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' })
|
@ApiOperation({
|
||||||
|
summary: '生成恢复码',
|
||||||
|
description: '验证助记词后生成一组一次性恢复码',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '恢复码列表' })
|
@ApiResponse({ status: 200, description: '恢复码列表' })
|
||||||
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) {
|
async generateBackupCodes(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: GenerateBackupCodesDto,
|
||||||
|
) {
|
||||||
return this.userService.generateBackupCodes({
|
return this.userService.generateBackupCodes({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
mnemonic: dto.mnemonic,
|
mnemonic: dto.mnemonic,
|
||||||
|
|
@ -300,7 +454,10 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('sms/send-withdraw-code')
|
@Post('sms/send-withdraw-code')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' })
|
@ApiOperation({
|
||||||
|
summary: '发送提取验证短信',
|
||||||
|
description: '向用户绑定的手机号发送提取验证码',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '发送成功' })
|
@ApiResponse({ status: 200, description: '发送成功' })
|
||||||
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
||||||
await this.userService.sendWithdrawSmsCode(user.userId);
|
await this.userService.sendWithdrawSmsCode(user.userId);
|
||||||
|
|
@ -309,31 +466,46 @@ export class UserAccountController {
|
||||||
|
|
||||||
@Post('sms/verify-withdraw-code')
|
@Post('sms/verify-withdraw-code')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' })
|
@ApiOperation({
|
||||||
|
summary: '验证提取短信验证码',
|
||||||
|
description: '验证提取操作的短信验证码',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
async verifyWithdrawSmsCode(
|
async verifyWithdrawSmsCode(
|
||||||
@CurrentUser() user: CurrentUserData,
|
@CurrentUser() user: CurrentUserData,
|
||||||
@Body() body: { code: string },
|
@Body() body: { code: string },
|
||||||
) {
|
) {
|
||||||
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code);
|
const valid = await this.userService.verifyWithdrawSmsCode(
|
||||||
|
user.userId,
|
||||||
|
body.code,
|
||||||
|
);
|
||||||
return { valid };
|
return { valid };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('verify-password')
|
@Post('verify-password')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' })
|
@ApiOperation({
|
||||||
|
summary: '验证登录密码',
|
||||||
|
description: '验证用户的登录密码,用于敏感操作二次验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
async verifyPassword(
|
async verifyPassword(
|
||||||
@CurrentUser() user: CurrentUserData,
|
@CurrentUser() user: CurrentUserData,
|
||||||
@Body() body: { password: string },
|
@Body() body: { password: string },
|
||||||
) {
|
) {
|
||||||
const valid = await this.userService.verifyPassword(user.userId, body.password);
|
const valid = await this.userService.verifyPassword(
|
||||||
|
user.userId,
|
||||||
|
body.password,
|
||||||
|
);
|
||||||
return { valid };
|
return { valid };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/resolve-address/:accountSequence')
|
@Get('users/resolve-address/:accountSequence')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' })
|
@ApiOperation({
|
||||||
|
summary: '解析充值ID到区块链地址',
|
||||||
|
description: '通过用户的 accountSequence 获取其区块链钱包地址',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
||||||
@ApiResponse({ status: 404, description: '找不到用户' })
|
@ApiResponse({ status: 404, description: '找不到用户' })
|
||||||
async resolveAccountSequenceToAddress(
|
async resolveAccountSequenceToAddress(
|
||||||
|
|
@ -376,7 +548,9 @@ export class UserAccountController {
|
||||||
|
|
||||||
// 验证文件类型
|
// 验证文件类型
|
||||||
if (!this.storageService.isValidImageType(file.mimetype)) {
|
if (!this.storageService.isValidImageType(file.mimetype)) {
|
||||||
throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp');
|
throw new BadRequestException(
|
||||||
|
'不支持的图片格式,请使用 jpg, png, gif 或 webp',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小
|
// 验证文件大小
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,14 @@ export * from './request';
|
||||||
export * from './response';
|
export * from './response';
|
||||||
|
|
||||||
// 其他通用DTOs
|
// 其他通用DTOs
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNotEmpty,
|
||||||
|
Matches,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class AutoLoginDto {
|
export class AutoLoginDto {
|
||||||
|
|
@ -144,7 +151,10 @@ export class RemoveDeviceDto {
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
export class AutoCreateAccountResponseDto {
|
export class AutoCreateAccountResponseDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
userSerialNum: string;
|
userSerialNum: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
||||||
|
|
@ -167,7 +177,10 @@ export class RecoverAccountResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|
@ -206,7 +219,10 @@ export class WalletStatusReadyResponseDto {
|
||||||
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
||||||
walletAddresses: WalletAddressesDto;
|
walletAddresses: WalletAddressesDto;
|
||||||
|
|
||||||
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
|
@ApiProperty({
|
||||||
|
example: 'word1 word2 ... word12',
|
||||||
|
description: '助记词 (12词)',
|
||||||
|
})
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +236,10 @@ export class LoginResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|
@ -233,7 +252,9 @@ export class LoginResponseDto {
|
||||||
// ============ Referral DTOs ============
|
// ============ Referral DTOs ============
|
||||||
|
|
||||||
export class GenerateReferralLinkDto {
|
export class GenerateReferralLinkDto {
|
||||||
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
|
@ApiPropertyOptional({
|
||||||
|
description: '渠道标识: wechat, telegram, twitter 等',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
|
@ -248,7 +269,10 @@ export class MeResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
|
|
@ -266,7 +290,11 @@ export class MeResponseDto {
|
||||||
@ApiProperty({ description: '完整推荐链接' })
|
@ApiProperty({ description: '完整推荐链接' })
|
||||||
referralLink: string;
|
referralLink: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '推荐人序列号',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
inviterSequence: string | null;
|
inviterSequence: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '钱包地址列表' })
|
@ApiProperty({ description: '钱包地址列表' })
|
||||||
|
|
@ -291,7 +319,7 @@ export class ReferralValidationResponseDto {
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '邀请人信息' })
|
@ApiPropertyOptional({ description: '邀请人信息' })
|
||||||
inviterInfo?: {
|
inviterInfo?: {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -324,7 +352,10 @@ export class ReferralLinkResponseDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InviteRecordDto {
|
export class InviteRecordDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches, IsObject } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNotEmpty,
|
||||||
|
Matches,
|
||||||
|
IsObject,
|
||||||
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -6,34 +12,40 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
||||||
*/
|
*/
|
||||||
export interface DeviceNameDto {
|
export interface DeviceNameDto {
|
||||||
model?: string; // 设备型号
|
model?: string; // 设备型号
|
||||||
platform?: string; // 平台: ios, android, web
|
platform?: string; // 平台: ios, android, web
|
||||||
osVersion?: string; // 系统版本
|
osVersion?: string; // 系统版本
|
||||||
brand?: string; // 品牌
|
brand?: string; // 品牌
|
||||||
manufacturer?: string; // 厂商
|
manufacturer?: string; // 厂商
|
||||||
device?: string; // 设备名
|
device?: string; // 设备名
|
||||||
product?: string; // 产品名
|
product?: string; // 产品名
|
||||||
hardware?: string; // 硬件名
|
hardware?: string; // 硬件名
|
||||||
sdkInt?: number; // SDK 版本 (Android)
|
sdkInt?: number; // SDK 版本 (Android)
|
||||||
isPhysicalDevice?: boolean; // 是否真机
|
isPhysicalDevice?: boolean; // 是否真机
|
||||||
[key: string]: unknown; // 允许其他字段
|
[key: string]: unknown; // 允许其他字段
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AutoCreateAccountDto {
|
export class AutoCreateAccountDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' })
|
@ApiProperty({
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
description: '设备唯一标识',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '设备信息 (JSON 对象)',
|
description: '设备信息 (JSON 对象)',
|
||||||
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }
|
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' },
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
deviceName?: DeviceNameDto;
|
deviceName?: DeviceNameDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
|
@ApiPropertyOptional({
|
||||||
|
example: 'RWAABC1234',
|
||||||
|
description: '邀请人推荐码 (6-20位大写字母和数字)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class GenerateBackupCodesDto {
|
export class GenerateBackupCodesDto {
|
||||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
@ApiProperty({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '当前助记词(验证身份用)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ export * from './generate-backup-codes.dto';
|
||||||
export * from './recover-by-backup-code.dto';
|
export * from './recover-by-backup-code.dto';
|
||||||
export * from './verify-sms-code.dto';
|
export * from './verify-sms-code.dto';
|
||||||
export * from './set-password.dto';
|
export * from './set-password.dto';
|
||||||
|
export * from './login-with-password.dto';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,9 @@ export class RecoverByBackupCodeDto {
|
||||||
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
|
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供恢复码' })
|
@IsNotEmpty({ message: '请提供恢复码' })
|
||||||
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, { message: '恢复码格式不正确' })
|
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, {
|
||||||
|
message: '恢复码格式不正确',
|
||||||
|
})
|
||||||
backupCode: string;
|
backupCode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,20 @@ import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RecoverByMnemonicDto {
|
export class RecoverByMnemonicDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
@Matches(/^D\d{11}$/, {
|
||||||
|
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' })
|
@ApiProperty({
|
||||||
|
example:
|
||||||
|
'abandon ability able about above absent absorb abstract absurd abuse access accident',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@ import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RecoverByPhoneDto {
|
export class RecoverByPhoneDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
@Matches(/^D\d{11}$/, {
|
||||||
|
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '13800138000' })
|
@ApiProperty({ example: '13800138000' })
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RequestKeyRotationDto {
|
export class RequestKeyRotationDto {
|
||||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
@ApiProperty({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '当前助记词(验证身份用)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||||
currentMnemonic: string;
|
currentMnemonic: string;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ export class SubmitKycDto {
|
||||||
|
|
||||||
@ApiProperty({ example: '110101199001011234' })
|
@ApiProperty({ example: '110101199001011234' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/, { message: '身份证号格式错误' })
|
@Matches(
|
||||||
|
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/,
|
||||||
|
{ message: '身份证号格式错误' },
|
||||||
|
)
|
||||||
idCardNumber: string;
|
idCardNumber: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,34 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UnfreezeAccountDto {
|
export class UnfreezeAccountDto {
|
||||||
@ApiProperty({ example: '确认账户安全', description: '解冻验证方式: mnemonic 或 phone' })
|
@ApiProperty({
|
||||||
|
example: '确认账户安全',
|
||||||
|
description: '解冻验证方式: mnemonic 或 phone',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
verifyMethod: 'mnemonic' | 'phone';
|
verifyMethod: 'mnemonic' | 'phone';
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'abandon abandon ...', description: '助记词 (verifyMethod=mnemonic时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '助记词 (verifyMethod=mnemonic时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
mnemonic?: string;
|
mnemonic?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '+8613800138000', description: '手机号 (verifyMethod=phone时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: '+8613800138000',
|
||||||
|
description: '手机号 (verifyMethod=phone时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '123456', description: '短信验证码 (verifyMethod=phone时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: '123456',
|
||||||
|
description: '短信验证码 (verifyMethod=phone时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
smsCode?: string;
|
smsCode?: string;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export class VerifySmsCodeDto {
|
||||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], { message: '无效的验证码类型' })
|
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
|
||||||
|
message: '无效的验证码类型',
|
||||||
|
})
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ export class UserProfileDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
accountSequence: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator';
|
import {
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
ValidationArguments,
|
||||||
|
registerDecorator,
|
||||||
|
ValidationOptions,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
||||||
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
||||||
|
|
@ -12,7 +18,7 @@ export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
||||||
return function (object: Object, propertyName: string) {
|
return function (object: object, propertyName: string) {
|
||||||
registerDecorator({
|
registerDecorator({
|
||||||
target: object.constructor,
|
target: object.constructor,
|
||||||
propertyName: propertyName,
|
propertyName: propertyName,
|
||||||
|
|
@ -26,7 +32,9 @@ export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
||||||
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
||||||
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
||||||
validate(idCard: string, args: ValidationArguments): boolean {
|
validate(idCard: string, args: ValidationArguments): boolean {
|
||||||
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard);
|
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
|
idCard,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(args: ValidationArguments): string {
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
|
@ -35,7 +43,7 @@ export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
||||||
return function (object: Object, propertyName: string) {
|
return function (object: object, propertyName: string) {
|
||||||
registerDecorator({
|
registerDecorator({
|
||||||
target: object.constructor,
|
target: object.constructor,
|
||||||
propertyName: propertyName,
|
propertyName: propertyName,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,15 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config';
|
import {
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
jwtConfig,
|
||||||
|
redisConfig,
|
||||||
|
kafkaConfig,
|
||||||
|
smsConfig,
|
||||||
|
walletConfig,
|
||||||
|
} from '@/config';
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
||||||
|
|
@ -25,7 +33,8 @@ import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
|
||||||
|
|
||||||
// Domain Services
|
// Domain Services
|
||||||
import {
|
import {
|
||||||
AccountSequenceGeneratorService, UserValidatorService,
|
AccountSequenceGeneratorService,
|
||||||
|
UserValidatorService,
|
||||||
} from '@/domain/services';
|
} from '@/domain/services';
|
||||||
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
|
|
@ -40,11 +49,17 @@ import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consum
|
||||||
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
||||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||||
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc';
|
import {
|
||||||
|
MpcClientService,
|
||||||
|
MpcWalletService,
|
||||||
|
} from '@/infrastructure/external/mpc';
|
||||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
|
import {
|
||||||
|
GlobalExceptionFilter,
|
||||||
|
TransformInterceptor,
|
||||||
|
} from '@/shared/filters/global-exception.filter';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
// ============ Infrastructure Module ============
|
// ============ Infrastructure Module ============
|
||||||
|
|
@ -122,7 +137,13 @@ export class ApplicationModule {}
|
||||||
// ============ API Module ============
|
// ============ API Module ============
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController],
|
controllers: [
|
||||||
|
HealthController,
|
||||||
|
UserAccountController,
|
||||||
|
ReferralsController,
|
||||||
|
AuthController,
|
||||||
|
TotpController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
||||||
|
|
@ -131,14 +152,24 @@ export class ApiModule {}
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
|
load: [
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
jwtConfig,
|
||||||
|
redisConfig,
|
||||||
|
kafkaConfig,
|
||||||
|
smsConfig,
|
||||||
|
walletConfig,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
global: true,
|
global: true,
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
InfrastructureModule,
|
InfrastructureModule,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
|
import {
|
||||||
|
AccountSequenceGeneratorService,
|
||||||
|
UserValidatorService,
|
||||||
|
} from '@/domain/services';
|
||||||
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
||||||
import { TokenService } from '@/application/services/token.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
|
|
@ -23,25 +29,34 @@ export class AutoCreateAccountHandler {
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
async execute(
|
||||||
|
command: AutoCreateAccountCommand,
|
||||||
|
): Promise<AutoCreateAccountResult> {
|
||||||
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
||||||
|
|
||||||
// 1. 验证设备ID
|
// 1. 验证设备ID
|
||||||
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
|
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
|
||||||
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
|
command.deviceId,
|
||||||
|
);
|
||||||
|
if (!deviceCheck.isValid)
|
||||||
|
throw new ApplicationError(deviceCheck.errorMessage!);
|
||||||
|
|
||||||
// 2. 验证邀请码
|
// 2. 验证邀请码
|
||||||
let inviterSequence: AccountSequence | null = null;
|
let inviterSequence: AccountSequence | null = null;
|
||||||
if (command.inviterReferralCode) {
|
if (command.inviterReferralCode) {
|
||||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||||
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
|
const referralValidation =
|
||||||
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
|
await this.validatorService.validateReferralCode(referralCode);
|
||||||
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
if (!referralValidation.isValid)
|
||||||
|
throw new ApplicationError(referralValidation.errorMessage!);
|
||||||
|
const inviter =
|
||||||
|
await this.userRepository.findByReferralCode(referralCode);
|
||||||
inviterSequence = inviter!.accountSequence;
|
inviterSequence = inviter!.accountSequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 生成用户序列号
|
// 3. 生成用户序列号
|
||||||
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
|
const accountSequence =
|
||||||
|
await this.sequenceGenerator.generateNextUserSequence();
|
||||||
|
|
||||||
// 4. 生成用户名和头像
|
// 4. 生成用户名和头像
|
||||||
const identity = generateIdentity(accountSequence.value);
|
const identity = generateIdentity(accountSequence.value);
|
||||||
|
|
@ -52,7 +67,8 @@ export class AutoCreateAccountHandler {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (command.deviceName.model) parts.push(command.deviceName.model);
|
if (command.deviceName.model) parts.push(command.deviceName.model);
|
||||||
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
||||||
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
|
if (command.deviceName.osVersion)
|
||||||
|
parts.push(command.deviceName.osVersion);
|
||||||
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +77,7 @@ export class AutoCreateAccountHandler {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
initialDeviceId: command.deviceId,
|
initialDeviceId: command.deviceId,
|
||||||
deviceName: deviceNameStr,
|
deviceName: deviceNameStr,
|
||||||
deviceInfo: command.deviceName, // 100% 保持原样存储
|
deviceInfo: command.deviceName, // 100% 保持原样存储
|
||||||
inviterSequence,
|
inviterSequence,
|
||||||
nickname: identity.username,
|
nickname: identity.username,
|
||||||
avatarSvg: identity.avatarSvg,
|
avatarSvg: identity.avatarSvg,
|
||||||
|
|
@ -81,7 +97,9 @@ export class AutoCreateAccountHandler {
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
account.clearDomainEvents();
|
account.clearDomainEvents();
|
||||||
|
|
||||||
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`);
|
this.logger.log(
|
||||||
|
`Account created: sequence=${accountSequence.value}, username=${identity.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userSerialNum: account.accountSequence.value,
|
userSerialNum: account.accountSequence.value,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { BindPhoneCommand } from './bind-phone.command';
|
import { BindPhoneCommand } from './bind-phone.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserValidatorService } from '@/domain/services';
|
import { UserValidatorService } from '@/domain/services';
|
||||||
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
|
|
@ -18,15 +21,22 @@ export class BindPhoneHandler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: BindPhoneCommand): Promise<void> {
|
async execute(command: BindPhoneCommand): Promise<void> {
|
||||||
const account = await this.userRepository.findById(UserId.create(command.userId));
|
const account = await this.userRepository.findById(
|
||||||
|
UserId.create(command.userId),
|
||||||
|
);
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
|
|
||||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||||
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`);
|
const cachedCode = await this.redisService.get(
|
||||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
`sms:bind:${phoneNumber.value}`,
|
||||||
|
);
|
||||||
|
if (cachedCode !== command.smsCode)
|
||||||
|
throw new ApplicationError('验证码错误或已过期');
|
||||||
|
|
||||||
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
|
const validation =
|
||||||
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!);
|
await this.validatorService.validatePhoneNumber(phoneNumber);
|
||||||
|
if (!validation.isValid)
|
||||||
|
throw new ApplicationError(validation.errorMessage!);
|
||||||
|
|
||||||
account.bindPhoneNumber(phoneNumber);
|
account.bindPhoneNumber(phoneNumber);
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
// 设备信息输入 - 100% 保持前端传递的原样存储
|
// 设备信息输入 - 100% 保持前端传递的原样存储
|
||||||
export interface DeviceNameInput {
|
export interface DeviceNameInput {
|
||||||
model?: string; // iPhone 15 Pro, Pixel 8
|
model?: string; // iPhone 15 Pro, Pixel 8
|
||||||
platform?: string; // ios, android, web
|
platform?: string; // ios, android, web
|
||||||
osVersion?: string; // iOS 17.2, Android 14
|
osVersion?: string; // iOS 17.2, Android 14
|
||||||
[key: string]: unknown; // 允许任意其他字段
|
[key: string]: unknown; // 允许任意其他字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Commands ============
|
// ============ Commands ============
|
||||||
|
|
@ -18,7 +18,7 @@ export class AutoCreateAccountCommand {
|
||||||
|
|
||||||
export class RecoverByMnemonicCommand {
|
export class RecoverByMnemonicCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly mnemonic: string,
|
public readonly mnemonic: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
|
|
@ -27,7 +27,7 @@ export class RecoverByMnemonicCommand {
|
||||||
|
|
||||||
export class RecoverByPhoneCommand {
|
export class RecoverByPhoneCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
|
|
@ -144,13 +144,13 @@ export class GetReferralStatsQuery {
|
||||||
export class GenerateReferralLinkCommand {
|
export class GenerateReferralLinkCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
||||||
public readonly campaignId?: string, // 活动ID
|
public readonly campaignId?: string, // 活动ID
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetWalletStatusQuery {
|
export class GetWalletStatusQuery {
|
||||||
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkMnemonicBackedUpCommand {
|
export class MarkMnemonicBackedUpCommand {
|
||||||
|
|
@ -184,21 +184,21 @@ export interface WalletStatusResult {
|
||||||
dst: string;
|
dst: string;
|
||||||
bsc: string;
|
bsc: string;
|
||||||
};
|
};
|
||||||
mnemonic?: string; // 助记词 (ready 状态时返回)
|
mnemonic?: string; // 助记词 (ready 状态时返回)
|
||||||
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
||||||
}
|
}
|
||||||
export interface AutoCreateAccountResult {
|
export interface AutoCreateAccountResult {
|
||||||
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
||||||
referralCode: string; // 推荐码
|
referralCode: string; // 推荐码
|
||||||
username: string; // 随机用户名
|
username: string; // 随机用户名
|
||||||
avatarSvg: string; // 随机SVG头像
|
avatarSvg: string; // 随机SVG头像
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecoverAccountResult {
|
export interface RecoverAccountResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
|
|
@ -208,14 +208,14 @@ export interface RecoverAccountResult {
|
||||||
|
|
||||||
export interface AutoLoginResult {
|
export interface AutoLoginResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResult {
|
export interface RegisterResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|
@ -223,14 +223,14 @@ export interface RegisterResult {
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileDTO {
|
export interface UserProfileDTO {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
|
@ -253,7 +253,7 @@ export interface DeviceDTO {
|
||||||
|
|
||||||
export interface UserBriefDTO {
|
export interface UserBriefDTO {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -262,7 +262,7 @@ export interface ReferralCodeValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
referralCode?: string;
|
referralCode?: string;
|
||||||
inviterInfo?: {
|
inviterInfo?: {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -281,29 +281,30 @@ export interface ReferralLinkResult {
|
||||||
|
|
||||||
export interface ReferralStatsResult {
|
export interface ReferralStatsResult {
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
totalInvites: number; // 总邀请人数
|
totalInvites: number; // 总邀请人数
|
||||||
directInvites: number; // 直接邀请人数
|
directInvites: number; // 直接邀请人数
|
||||||
indirectInvites: number; // 间接邀请人数 (二级)
|
indirectInvites: number; // 间接邀请人数 (二级)
|
||||||
todayInvites: number; // 今日邀请
|
todayInvites: number; // 今日邀请
|
||||||
thisWeekInvites: number; // 本周邀请
|
thisWeekInvites: number; // 本周邀请
|
||||||
thisMonthInvites: number; // 本月邀请
|
thisMonthInvites: number; // 本月邀请
|
||||||
recentInvites: Array<{ // 最近邀请记录
|
recentInvites: Array<{
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
// 最近邀请记录
|
||||||
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
level: number; // 1=直接, 2=间接
|
level: number; // 1=直接, 2=间接
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeResult {
|
export interface MeResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
referralLink: string; // 完整推荐链接
|
referralLink: string; // 完整推荐链接
|
||||||
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
||||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||||
kycStatus: string;
|
kycStatus: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class RecoverByMnemonicCommand {
|
export class RecoverByMnemonicCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly mnemonic: string,
|
public readonly mnemonic: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { AccountSequence } from '@/domain/value-objects';
|
import { AccountSequence } from '@/domain/value-objects';
|
||||||
import { TokenService } from '@/application/services/token.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
|
|
@ -21,34 +24,49 @@ export class RecoverByMnemonicHandler {
|
||||||
private readonly blockchainClient: BlockchainClientService,
|
private readonly blockchainClient: BlockchainClientService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
async execute(
|
||||||
|
command: RecoverByMnemonicCommand,
|
||||||
|
): Promise<RecoverAccountResult> {
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
const account =
|
||||||
|
await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
|
|
||||||
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
||||||
this.logger.log(`Verifying mnemonic for account ${command.accountSequence}`);
|
this.logger.log(
|
||||||
|
`Verifying mnemonic for account ${command.accountSequence}`,
|
||||||
|
);
|
||||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||||
accountSequence: command.accountSequence,
|
accountSequence: command.accountSequence,
|
||||||
mnemonic: command.mnemonic,
|
mnemonic: command.mnemonic,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!verifyResult.valid) {
|
if (!verifyResult.valid) {
|
||||||
this.logger.warn(`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`);
|
this.logger.warn(
|
||||||
|
`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`,
|
||||||
|
);
|
||||||
throw new ApplicationError(verifyResult.message || '助记词错误');
|
throw new ApplicationError(verifyResult.message || '助记词错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`);
|
this.logger.log(
|
||||||
|
`Mnemonic verified successfully for account ${command.accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 如果头像为空,重新生成一个
|
// 如果头像为空,重新生成一个
|
||||||
let avatarUrl = account.avatarUrl;
|
let avatarUrl = account.avatarUrl;
|
||||||
this.logger.log(`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`);
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`,
|
||||||
|
);
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
this.logger.log(`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`);
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||||
|
);
|
||||||
avatarUrl = generateRandomAvatarSvg();
|
avatarUrl = generateRandomAvatarSvg();
|
||||||
account.updateProfile({ avatarUrl });
|
account.updateProfile({ avatarUrl });
|
||||||
}
|
}
|
||||||
|
|
@ -76,8 +94,12 @@ export class RecoverByMnemonicHandler {
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`);
|
this.logger.log(
|
||||||
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`);
|
`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class RecoverByPhoneCommand {
|
export class RecoverByPhoneCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||||
import { TokenService } from '@/application/services/token.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
|
|
@ -23,21 +26,29 @@ export class RecoverByPhoneHandler {
|
||||||
|
|
||||||
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
const account =
|
||||||
|
await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
if (!account.phoneNumber)
|
||||||
|
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||||
|
|
||||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||||
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
|
if (!account.phoneNumber.equals(phoneNumber))
|
||||||
|
throw new ApplicationError('手机号与账户不匹配');
|
||||||
|
|
||||||
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
|
const cachedCode = await this.redisService.get(
|
||||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
`sms:recover:${phoneNumber.value}`,
|
||||||
|
);
|
||||||
|
if (cachedCode !== command.smsCode)
|
||||||
|
throw new ApplicationError('验证码错误或已过期');
|
||||||
|
|
||||||
// 如果头像为空,重新生成一个
|
// 如果头像为空,重新生成一个
|
||||||
let avatarUrl = account.avatarUrl;
|
let avatarUrl = account.avatarUrl;
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||||
|
);
|
||||||
avatarUrl = generateRandomAvatarSvg();
|
avatarUrl = generateRandomAvatarSvg();
|
||||||
account.updateProfile({ avatarUrl });
|
account.updateProfile({ avatarUrl });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { ChainType, UserId } from '@/domain/value-objects';
|
import { ChainType, UserId } from '@/domain/value-objects';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
|
|
@ -31,7 +34,7 @@ interface WalletCompletedStatusData {
|
||||||
userId: string;
|
userId: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
walletAddresses?: { chainType: string; address: string }[];
|
walletAddresses?: { chainType: string; address: string }[];
|
||||||
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,8 +52,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// Register event handler
|
// Register event handler
|
||||||
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this));
|
this.blockchainEventConsumer.onWalletAddressCreated(
|
||||||
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events');
|
this.handleWalletAddressCreated.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
'[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,21 +68,36 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
* - DST: dst1... (Cosmos bech32)
|
* - DST: dst1... (Cosmos bech32)
|
||||||
* - BSC: 0x... (EVM)
|
* - BSC: 0x... (EVM)
|
||||||
*/
|
*/
|
||||||
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
|
private async handleWalletAddressCreated(
|
||||||
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload;
|
payload: WalletAddressCreatedPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
publicKey,
|
||||||
|
addresses,
|
||||||
|
mnemonic,
|
||||||
|
encryptedMnemonic,
|
||||||
|
mnemonicHash,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated: userId=${userId}`);
|
this.logger.log(
|
||||||
|
`[HANDLE] Processing WalletAddressCreated: userId=${userId}`,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||||
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
||||||
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
|
this.logger.error(
|
||||||
|
'[ERROR] WalletAddressCreated event missing userId, skipping',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!addresses || addresses.length === 0) {
|
if (!addresses || addresses.length === 0) {
|
||||||
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping');
|
this.logger.error(
|
||||||
|
'[ERROR] WalletAddressCreated event missing addresses, skipping',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,23 +112,29 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
// 2. Create wallet addresses for each chain (with publicKey)
|
// 2. Create wallet addresses for each chain (with publicKey)
|
||||||
const wallets: WalletAddress[] = addresses.map((addr) => {
|
const wallets: WalletAddress[] = addresses.map((addr) => {
|
||||||
const chainType = this.parseChainType(addr.chainType);
|
const chainType = this.parseChainType(addr.chainType);
|
||||||
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`);
|
this.logger.log(
|
||||||
|
`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`,
|
||||||
|
);
|
||||||
return WalletAddress.create({
|
return WalletAddress.create({
|
||||||
userId: account.userId,
|
userId: account.userId,
|
||||||
chainType,
|
chainType,
|
||||||
address: addr.address,
|
address: addr.address,
|
||||||
publicKey, // 传入公钥,用于关联助记词
|
publicKey, // 传入公钥,用于关联助记词
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Save wallet addresses to user account
|
// 3. Save wallet addresses to user account
|
||||||
await this.userRepository.saveWallets(account.userId, wallets);
|
await this.userRepository.saveWallets(account.userId, wallets);
|
||||||
this.logger.log(`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
|
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
|
||||||
// Note: blockchain-service stores mnemonic with accountSequence association
|
// Note: blockchain-service stores mnemonic with accountSequence association
|
||||||
if (mnemonic) {
|
if (mnemonic) {
|
||||||
this.logger.log(`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`);
|
this.logger.log(
|
||||||
|
`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
||||||
|
|
@ -116,7 +144,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
userId,
|
userId,
|
||||||
publicKey,
|
publicKey,
|
||||||
walletAddresses: addresses,
|
walletAddresses: addresses,
|
||||||
mnemonic, // 首次返回明文助记词
|
mnemonic, // 首次返回明文助记词
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,9 +156,13 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'completed' for user: ${userId}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`);
|
this.logger.log(
|
||||||
|
`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log all addresses
|
// Log all addresses
|
||||||
|
|
@ -138,7 +170,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
|
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to process WalletAddressCreated: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to process WalletAddressCreated: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -158,9 +193,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
case 'BSC':
|
case 'BSC':
|
||||||
return ChainType.BSC;
|
return ChainType.BSC;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`);
|
this.logger.warn(
|
||||||
|
`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`,
|
||||||
|
);
|
||||||
return ChainType.BSC;
|
return ChainType.BSC;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,12 @@ import {
|
||||||
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
|
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
|
||||||
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
|
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
|
||||||
|
|
||||||
export type KeygenStatus = 'pending' | 'generating' | 'deriving' | 'completed' | 'failed';
|
export type KeygenStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'generating'
|
||||||
|
| 'deriving'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
export interface KeygenStatusData {
|
export interface KeygenStatusData {
|
||||||
status: KeygenStatus;
|
status: KeygenStatus;
|
||||||
|
|
@ -48,9 +53,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
|
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
|
||||||
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
|
this.mpcEventConsumer.onKeygenCompleted(
|
||||||
|
this.handleKeygenCompleted.bind(this),
|
||||||
|
);
|
||||||
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
||||||
this.logger.log('[INIT] Registered MPC event handlers (status updates only)');
|
this.logger.log(
|
||||||
|
'[INIT] Registered MPC event handlers (status updates only)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,9 +67,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
*
|
*
|
||||||
* Update Redis status to "generating"
|
* Update Redis status to "generating"
|
||||||
*/
|
*/
|
||||||
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> {
|
private async handleKeygenStarted(
|
||||||
|
payload: KeygenStartedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { userId, mpcSessionId } = payload;
|
const { userId, mpcSessionId } = payload;
|
||||||
this.logger.log(`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statusData: KeygenStatusData = {
|
const statusData: KeygenStatusData = {
|
||||||
|
|
@ -76,9 +89,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
KEYGEN_STATUS_TTL,
|
KEYGEN_STATUS_TTL,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'generating' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'generating' for user: ${userId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +112,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
* Uses atomic Redis update to ensure status only advances forward:
|
* Uses atomic Redis update to ensure status only advances forward:
|
||||||
* pending -> generating -> deriving -> completed
|
* pending -> generating -> deriving -> completed
|
||||||
*/
|
*/
|
||||||
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
|
private async handleKeygenCompleted(
|
||||||
|
payload: KeygenCompletedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { publicKey, extraPayload } = payload;
|
const { publicKey, extraPayload } = payload;
|
||||||
|
|
||||||
if (!extraPayload?.userId) {
|
if (!extraPayload?.userId) {
|
||||||
|
|
@ -103,11 +123,15 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, username } = extraPayload;
|
const { userId, username } = extraPayload;
|
||||||
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen completed: userId=${userId}, username=${username}`,
|
||||||
|
);
|
||||||
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
|
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
|
this.logger.log(
|
||||||
|
`[STATUS] Waiting for blockchain-service to derive addresses...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Update status to "deriving" - waiting for blockchain-service
|
// Update status to "deriving" - waiting for blockchain-service
|
||||||
// Uses atomic operation to ensure we don't overwrite higher-priority status
|
// Uses atomic operation to ensure we don't overwrite higher-priority status
|
||||||
|
|
@ -126,13 +150,22 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
|
this.logger.log(
|
||||||
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
|
`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`);
|
this.logger.log(
|
||||||
|
`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +178,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
* 1. Log error
|
* 1. Log error
|
||||||
* 2. Update Redis status to "failed"
|
* 2. Update Redis status to "failed"
|
||||||
*/
|
*/
|
||||||
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
|
private async handleSessionFailed(
|
||||||
|
payload: SessionFailedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { sessionType, errorMessage, extraPayload } = payload;
|
const { sessionType, errorMessage, extraPayload } = payload;
|
||||||
|
|
||||||
// Only handle keygen failures
|
// Only handle keygen failures
|
||||||
|
|
@ -154,7 +189,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = extraPayload?.userId || 'unknown';
|
const userId = extraPayload?.userId || 'unknown';
|
||||||
this.logger.error(`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`);
|
this.logger.error(
|
||||||
|
`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update Redis status to failed
|
// Update Redis status to failed
|
||||||
|
|
@ -171,9 +208,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
KEYGEN_STATUS_TTL,
|
KEYGEN_STATUS_TTL,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'failed' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'failed' for user: ${userId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen failed status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen failed status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { GetMyDevicesQuery } from './get-my-devices.query';
|
import { GetMyDevicesQuery } from './get-my-devices.query';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserId } from '@/domain/value-objects';
|
import { UserId } from '@/domain/value-objects';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
import { DeviceDTO } from '@/application/commands';
|
import { DeviceDTO } from '@/application/commands';
|
||||||
|
|
@ -13,7 +16,9 @@ export class GetMyDevicesHandler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
||||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
const account = await this.userRepository.findById(
|
||||||
|
UserId.create(query.userId),
|
||||||
|
);
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
|
|
||||||
return account.getAllDevices().map((device) => ({
|
return account.getAllDevices().map((device) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { GetMyProfileQuery } from './get-my-profile.query';
|
import { GetMyProfileQuery } from './get-my-profile.query';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { UserId } from '@/domain/value-objects';
|
import { UserId } from '@/domain/value-objects';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
@ -14,7 +17,9 @@ export class GetMyProfileHandler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
||||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
const account = await this.userRepository.findById(
|
||||||
|
UserId.create(query.userId),
|
||||||
|
);
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
return this.toDTO(account);
|
return this.toDTO(account);
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +38,10 @@ export class GetMyProfileHandler {
|
||||||
})),
|
})),
|
||||||
kycStatus: account.kycStatus,
|
kycStatus: account.kycStatus,
|
||||||
kycInfo: account.kycInfo
|
kycInfo: account.kycInfo
|
||||||
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
|
? {
|
||||||
|
realName: account.kycInfo.realName,
|
||||||
|
idCardNumber: account.kycInfo.maskedIdCardNumber(),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
status: account.status,
|
status: account.status,
|
||||||
registeredAt: account.registeredAt,
|
registeredAt: account.registeredAt,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
@ -22,17 +22,27 @@ export class TokenService {
|
||||||
|
|
||||||
async generateTokenPair(payload: {
|
async generateTokenPair(payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}): Promise<{ accessToken: string; refreshToken: string }> {
|
}): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
const accessToken = this.jwtService.sign(
|
const accessToken = this.jwtService.sign(
|
||||||
{ ...payload, type: 'access' },
|
{ ...payload, type: 'access' },
|
||||||
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
{
|
||||||
|
expiresIn: this.configService.get<string>(
|
||||||
|
'JWT_ACCESS_EXPIRES_IN',
|
||||||
|
'2h',
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshToken = this.jwtService.sign(
|
const refreshToken = this.jwtService.sign(
|
||||||
{ ...payload, type: 'refresh' },
|
{ ...payload, type: 'refresh' },
|
||||||
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') },
|
{
|
||||||
|
expiresIn: this.configService.get<string>(
|
||||||
|
'JWT_REFRESH_EXPIRES_IN',
|
||||||
|
'30d',
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save refresh token hash
|
// Save refresh token hash
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ export class TotpService {
|
||||||
private readonly logger = new Logger(TotpService.name);
|
private readonly logger = new Logger(TotpService.name);
|
||||||
|
|
||||||
// TOTP 配置
|
// TOTP 配置
|
||||||
private readonly TOTP_DIGITS = 6; // 验证码位数
|
private readonly TOTP_DIGITS = 6; // 验证码位数
|
||||||
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
||||||
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
||||||
private readonly ISSUER = 'RWADurian'; // 应用名称
|
private readonly ISSUER = 'RWADurian'; // 应用名称
|
||||||
|
|
||||||
// AES 加密密钥 (生产环境应从环境变量获取)
|
// AES 加密密钥 (生产环境应从环境变量获取)
|
||||||
private readonly ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
private readonly ENCRYPTION_KEY =
|
||||||
|
process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,35 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { UserApplicationService } from './user-application.service';
|
import { UserApplicationService } from './user-application.service';
|
||||||
import { USER_ACCOUNT_REPOSITORY, UserAccountRepository, ReferralLinkData, CreateReferralLinkParams } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { MPC_KEY_SHARE_REPOSITORY, MpcKeyShareRepository } from '@/domain/repositories/mpc-key-share.repository.interface';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
UserAccountRepository,
|
||||||
|
ReferralLinkData,
|
||||||
|
CreateReferralLinkParams,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
|
import {
|
||||||
|
MPC_KEY_SHARE_REPOSITORY,
|
||||||
|
MpcKeyShareRepository,
|
||||||
|
} from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { AccountSequence, ReferralCode, UserId, AccountStatus, KYCStatus, DeviceInfo } from '@/domain/value-objects';
|
import {
|
||||||
|
AccountSequence,
|
||||||
|
ReferralCode,
|
||||||
|
UserId,
|
||||||
|
AccountStatus,
|
||||||
|
KYCStatus,
|
||||||
|
DeviceInfo,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand } from '@/application/commands';
|
import {
|
||||||
|
ValidateReferralCodeQuery,
|
||||||
|
GetReferralStatsQuery,
|
||||||
|
GenerateReferralLinkCommand,
|
||||||
|
} from '@/application/commands';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
|
import {
|
||||||
|
AccountSequenceGeneratorService,
|
||||||
|
UserValidatorService,
|
||||||
|
} from '@/domain/services';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||||
|
|
@ -23,16 +45,18 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
let mockUserRepository: jest.Mocked<UserAccountRepository>;
|
let mockUserRepository: jest.Mocked<UserAccountRepository>;
|
||||||
|
|
||||||
// Helper function to create a test account using UserAccount.reconstruct
|
// Helper function to create a test account using UserAccount.reconstruct
|
||||||
const createMockAccount = (params: {
|
const createMockAccount = (
|
||||||
userId?: string;
|
params: {
|
||||||
accountSequence?: string;
|
userId?: string;
|
||||||
referralCode?: string;
|
accountSequence?: string;
|
||||||
nickname?: string;
|
referralCode?: string;
|
||||||
avatarUrl?: string | null;
|
nickname?: string;
|
||||||
isActive?: boolean;
|
avatarUrl?: string | null;
|
||||||
inviterSequence?: string | null;
|
isActive?: boolean;
|
||||||
registeredAt?: Date;
|
inviterSequence?: string | null;
|
||||||
} = {}): UserAccount => {
|
registeredAt?: Date;
|
||||||
|
} = {},
|
||||||
|
): UserAccount => {
|
||||||
const devices = [
|
const devices = [
|
||||||
new DeviceInfo('device-001', 'Test Device', new Date(), new Date()),
|
new DeviceInfo('device-001', 'Test Device', new Date(), new Date()),
|
||||||
];
|
];
|
||||||
|
|
@ -49,7 +73,8 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
walletAddresses: [],
|
walletAddresses: [],
|
||||||
kycInfo: null,
|
kycInfo: null,
|
||||||
kycStatus: KYCStatus.NOT_VERIFIED,
|
kycStatus: KYCStatus.NOT_VERIFIED,
|
||||||
status: params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN,
|
status:
|
||||||
|
params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN,
|
||||||
registeredAt: params.registeredAt || new Date(),
|
registeredAt: params.registeredAt || new Date(),
|
||||||
lastLoginAt: null,
|
lastLoginAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -86,15 +111,17 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
const mockConfigService = {
|
const mockConfigService = {
|
||||||
get: jest.fn((key: string) => {
|
get: jest.fn((key: string) => {
|
||||||
const config: Record<string, any> = {
|
const config: Record<string, any> = {
|
||||||
'APP_BASE_URL': 'https://app.rwadurian.com',
|
APP_BASE_URL: 'https://app.rwadurian.com',
|
||||||
'MPC_MODE': 'local',
|
MPC_MODE: 'local',
|
||||||
};
|
};
|
||||||
return config[key];
|
return config[key];
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAccountSequenceGeneratorService = {
|
const mockAccountSequenceGeneratorService = {
|
||||||
getNext: jest.fn().mockResolvedValue(AccountSequence.create('D2412190001')),
|
getNext: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(AccountSequence.create('D2412190001')),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserValidatorService = {
|
const mockUserValidatorService = {
|
||||||
|
|
@ -108,7 +135,9 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
const mockTokenService = {
|
const mockTokenService = {
|
||||||
generateAccessToken: jest.fn().mockReturnValue('mock-access-token'),
|
generateAccessToken: jest.fn().mockReturnValue('mock-access-token'),
|
||||||
generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'),
|
generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'),
|
||||||
generateDeviceRefreshToken: jest.fn().mockReturnValue('mock-device-refresh-token'),
|
generateDeviceRefreshToken: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('mock-device-refresh-token'),
|
||||||
verifyRefreshToken: jest.fn(),
|
verifyRefreshToken: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -240,14 +269,18 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
registeredAt: expect.any(Date),
|
registeredAt: expect.any(Date),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUserRepository.findById).toHaveBeenCalledWith(expect.any(UserId));
|
expect(mockUserRepository.findById).toHaveBeenCalledWith(
|
||||||
|
expect.any(UserId),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when user not found', async () => {
|
it('should throw error when user not found', async () => {
|
||||||
mockUserRepository.findById.mockResolvedValue(null);
|
mockUserRepository.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
// Use valid numeric string for userId
|
// Use valid numeric string for userId
|
||||||
await expect(service.getMe('999999999')).rejects.toThrow(ApplicationError);
|
await expect(service.getMe('999999999')).rejects.toThrow(
|
||||||
|
ApplicationError,
|
||||||
|
);
|
||||||
await expect(service.getMe('999999999')).rejects.toThrow('用户不存在');
|
await expect(service.getMe('999999999')).rejects.toThrow('用户不存在');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -266,7 +299,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter);
|
mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter);
|
||||||
|
|
||||||
const result = await service.validateReferralCode(
|
const result = await service.validateReferralCode(
|
||||||
new ValidateReferralCodeQuery('INVTE1')
|
new ValidateReferralCodeQuery('INVTE1'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -284,7 +317,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.findByReferralCode.mockResolvedValue(null);
|
mockUserRepository.findByReferralCode.mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await service.validateReferralCode(
|
const result = await service.validateReferralCode(
|
||||||
new ValidateReferralCodeQuery('INVLD1')
|
new ValidateReferralCodeQuery('INVLD1'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -302,7 +335,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter);
|
mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter);
|
||||||
|
|
||||||
const result = await service.validateReferralCode(
|
const result = await service.validateReferralCode(
|
||||||
new ValidateReferralCodeQuery('FROZN1')
|
new ValidateReferralCodeQuery('FROZN1'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -313,7 +346,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
|
|
||||||
it('should return valid=false for invalid referral code format', async () => {
|
it('should return valid=false for invalid referral code format', async () => {
|
||||||
const result = await service.validateReferralCode(
|
const result = await service.validateReferralCode(
|
||||||
new ValidateReferralCodeQuery('invalid-format-too-long')
|
new ValidateReferralCodeQuery('invalid-format-too-long'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
|
|
@ -343,13 +376,15 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||||
|
|
||||||
const result = await service.generateReferralLink(
|
const result = await service.generateReferralLink(
|
||||||
new GenerateReferralLinkCommand('123456789', 'wechat')
|
new GenerateReferralLinkCommand('123456789', 'wechat'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
linkId: '1',
|
linkId: '1',
|
||||||
referralCode: 'ABC123',
|
referralCode: 'ABC123',
|
||||||
shortUrl: expect.stringMatching(/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/),
|
shortUrl: expect.stringMatching(
|
||||||
|
/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/,
|
||||||
|
),
|
||||||
fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat',
|
fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat',
|
||||||
channel: 'wechat',
|
channel: 'wechat',
|
||||||
campaignId: null,
|
campaignId: null,
|
||||||
|
|
@ -385,7 +420,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||||
|
|
||||||
const result = await service.generateReferralLink(
|
const result = await service.generateReferralLink(
|
||||||
new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024')
|
new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.channel).toBe('telegram');
|
expect(result.channel).toBe('telegram');
|
||||||
|
|
@ -412,7 +447,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||||
|
|
||||||
const result = await service.generateReferralLink(
|
const result = await service.generateReferralLink(
|
||||||
new GenerateReferralLinkCommand('123456789')
|
new GenerateReferralLinkCommand('123456789'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.fullUrl).toContain('ch=default');
|
expect(result.fullUrl).toContain('ch=default');
|
||||||
|
|
@ -424,7 +459,9 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
|
|
||||||
// Use valid numeric string for userId
|
// Use valid numeric string for userId
|
||||||
await expect(
|
await expect(
|
||||||
service.generateReferralLink(new GenerateReferralLinkCommand('999999999'))
|
service.generateReferralLink(
|
||||||
|
new GenerateReferralLinkCommand('999999999'),
|
||||||
|
),
|
||||||
).rejects.toThrow(ApplicationError);
|
).rejects.toThrow(ApplicationError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -471,7 +508,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
.mockResolvedValueOnce([]); // Indirect invites via user 3 (none)
|
.mockResolvedValueOnce([]); // Indirect invites via user 3 (none)
|
||||||
|
|
||||||
const result = await service.getReferralStats(
|
const result = await service.getReferralStats(
|
||||||
new GetReferralStatsQuery('123456789')
|
new GetReferralStatsQuery('123456789'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -505,7 +542,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
mockUserRepository.findByInviterSequence.mockResolvedValue([]);
|
mockUserRepository.findByInviterSequence.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.getReferralStats(
|
const result = await service.getReferralStats(
|
||||||
new GetReferralStatsQuery('123456789')
|
new GetReferralStatsQuery('123456789'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -555,7 +592,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
.mockResolvedValue([]); // No second-level invites
|
.mockResolvedValue([]); // No second-level invites
|
||||||
|
|
||||||
const result = await service.getReferralStats(
|
const result = await service.getReferralStats(
|
||||||
new GetReferralStatsQuery('123456789')
|
new GetReferralStatsQuery('123456789'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.directInvites).toBe(3);
|
expect(result.directInvites).toBe(3);
|
||||||
|
|
@ -567,7 +604,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
|
|
||||||
// Use valid numeric string for userId
|
// Use valid numeric string for userId
|
||||||
await expect(
|
await expect(
|
||||||
service.getReferralStats(new GetReferralStatsQuery('999999999'))
|
service.getReferralStats(new GetReferralStatsQuery('999999999')),
|
||||||
).rejects.toThrow(ApplicationError);
|
).rejects.toThrow(ApplicationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -599,7 +636,7 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.getReferralStats(
|
const result = await service.getReferralStats(
|
||||||
new GetReferralStatsQuery('123456789')
|
new GetReferralStatsQuery('123456789'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Newest should be first
|
// Newest should be first
|
||||||
|
|
@ -630,15 +667,18 @@ describe('UserApplicationService - Referral APIs', () => {
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserRepository.createReferralLink.mockResolvedValueOnce(mockLinkData);
|
mockUserRepository.createReferralLink.mockResolvedValueOnce(
|
||||||
|
mockLinkData,
|
||||||
|
);
|
||||||
|
|
||||||
await service.generateReferralLink(
|
await service.generateReferralLink(
|
||||||
new GenerateReferralLinkCommand('123456789', `channel${i}`)
|
new GenerateReferralLinkCommand('123456789', `channel${i}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All generated short codes should have 6 characters
|
// All generated short codes should have 6 characters
|
||||||
const createReferralLinkCalls = mockUserRepository.createReferralLink.mock.calls;
|
const createReferralLinkCalls =
|
||||||
|
mockUserRepository.createReferralLink.mock.calls;
|
||||||
createReferralLinkCalls.forEach((call) => {
|
createReferralLinkCalls.forEach((call) => {
|
||||||
const params = call[0] as CreateReferralLinkParams;
|
const params = call[0] as CreateReferralLinkParams;
|
||||||
expect(params.shortCode).toHaveLength(6);
|
expect(params.shortCode).toHaveLength(6);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { UserId } from '@/domain/value-objects';
|
import { UserId } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
|
@ -64,7 +67,9 @@ export class WalletRetryTask {
|
||||||
async handleWalletRetry() {
|
async handleWalletRetry() {
|
||||||
// 防止并发执行
|
// 防止并发执行
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
this.logger.warn('[TASK] Previous task still running, skipping this execution');
|
this.logger.warn(
|
||||||
|
'[TASK] Previous task still running, skipping this execution',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +78,12 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 扫描所有 keygen:status:* keys
|
// 1. 扫描所有 keygen:status:* keys
|
||||||
const statusKeys = await this.redisService.keys(`${KEYGEN_STATUS_PREFIX}*`);
|
const statusKeys = await this.redisService.keys(
|
||||||
this.logger.log(`[TASK] Found ${statusKeys.length} wallet generation records`);
|
`${KEYGEN_STATUS_PREFIX}*`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[TASK] Found ${statusKeys.length} wallet generation records`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const key of statusKeys) {
|
for (const key of statusKeys) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -123,7 +132,9 @@ export class WalletRetryTask {
|
||||||
// 检查重试限制
|
// 检查重试限制
|
||||||
const canRetry = await this.checkRetryLimit(userId);
|
const canRetry = await this.checkRetryLimit(userId);
|
||||||
if (!canRetry) {
|
if (!canRetry) {
|
||||||
this.logger.warn(`[TASK] User ${userId} exceeded retry time limit (10 minutes)`);
|
this.logger.warn(
|
||||||
|
`[TASK] User ${userId} exceeded retry time limit (10 minutes)`,
|
||||||
|
);
|
||||||
// 更新状态为最终失败
|
// 更新状态为最终失败
|
||||||
await this.markAsFinalFailure(userId);
|
await this.markAsFinalFailure(userId);
|
||||||
return;
|
return;
|
||||||
|
|
@ -144,19 +155,25 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
// 情况1:状态为 failed
|
// 情况1:状态为 failed
|
||||||
if (currentStatus === 'failed') {
|
if (currentStatus === 'failed') {
|
||||||
this.logger.log(`[TASK] User ${status.userId} status is 'failed', will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} status is 'failed', will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况2:状态为 generating 但超过 60 秒
|
// 情况2:状态为 generating 但超过 60 秒
|
||||||
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
|
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||||
this.logger.log(`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况3:状态为 deriving 但超过 60 秒
|
// 情况3:状态为 deriving 但超过 60 秒
|
||||||
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
|
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||||
this.logger.log(`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,7 +206,9 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
// 如果超过 10 分钟,不再重试
|
// 如果超过 10 分钟,不再重试
|
||||||
if (elapsed > MAX_RETRY_DURATION_MS) {
|
if (elapsed > MAX_RETRY_DURATION_MS) {
|
||||||
this.logger.warn(`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`);
|
this.logger.warn(
|
||||||
|
`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +242,9 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
await this.eventPublisher.publish(event);
|
await this.eventPublisher.publish(event);
|
||||||
|
|
||||||
this.logger.log(`[TASK] Wallet generation retry triggered for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[TASK] Wallet generation retry triggered for user: ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 4. 更新 Redis 状态为 pending(等待重新生成)
|
// 4. 更新 Redis 状态为 pending(等待重新生成)
|
||||||
const statusData: KeygenStatusData = {
|
const statusData: KeygenStatusData = {
|
||||||
|
|
@ -238,7 +259,10 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,7 +298,9 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`);
|
this.logger.log(
|
||||||
|
`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -294,6 +320,8 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.error(`[TASK] Marked user ${userId} as final failure after retry timeout`);
|
this.logger.error(
|
||||||
|
`[TASK] Marked user ${userId} as final failure after retry timeout`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,30 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import {
|
import {
|
||||||
UserId, AccountSequence, PhoneNumber, ReferralCode,
|
UserId,
|
||||||
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
|
AccountSequence,
|
||||||
|
PhoneNumber,
|
||||||
|
ReferralCode,
|
||||||
|
DeviceInfo,
|
||||||
|
ChainType,
|
||||||
|
KYCInfo,
|
||||||
|
KYCStatus,
|
||||||
|
AccountStatus,
|
||||||
} from '@/domain/value-objects';
|
} from '@/domain/value-objects';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import {
|
import {
|
||||||
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
|
DomainEvent,
|
||||||
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
|
UserAccountAutoCreatedEvent,
|
||||||
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
|
UserAccountCreatedEvent,
|
||||||
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
|
DeviceAddedEvent,
|
||||||
UserAccountFrozenEvent, UserAccountDeactivatedEvent,
|
DeviceRemovedEvent,
|
||||||
|
PhoneNumberBoundEvent,
|
||||||
|
WalletAddressBoundEvent,
|
||||||
|
MultipleWalletAddressesBoundEvent,
|
||||||
|
KYCSubmittedEvent,
|
||||||
|
KYCVerifiedEvent,
|
||||||
|
KYCRejectedEvent,
|
||||||
|
UserAccountFrozenEvent,
|
||||||
|
UserAccountDeactivatedEvent,
|
||||||
} from '@/domain/events';
|
} from '@/domain/events';
|
||||||
|
|
||||||
export class UserAccount {
|
export class UserAccount {
|
||||||
|
|
@ -31,30 +46,71 @@ export class UserAccount {
|
||||||
private _domainEvents: DomainEvent[] = [];
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get userId(): UserId { return this._userId; }
|
get userId(): UserId {
|
||||||
get accountSequence(): AccountSequence { return this._accountSequence; }
|
return this._userId;
|
||||||
get phoneNumber(): PhoneNumber | null { return this._phoneNumber; }
|
}
|
||||||
get nickname(): string { return this._nickname; }
|
get accountSequence(): AccountSequence {
|
||||||
get avatarUrl(): string | null { return this._avatarUrl; }
|
return this._accountSequence;
|
||||||
get inviterSequence(): AccountSequence | null { return this._inviterSequence; }
|
}
|
||||||
get referralCode(): ReferralCode { return this._referralCode; }
|
get phoneNumber(): PhoneNumber | null {
|
||||||
get kycInfo(): KYCInfo | null { return this._kycInfo; }
|
return this._phoneNumber;
|
||||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
}
|
||||||
get status(): AccountStatus { return this._status; }
|
get nickname(): string {
|
||||||
get registeredAt(): Date { return this._registeredAt; }
|
return this._nickname;
|
||||||
get lastLoginAt(): Date | null { return this._lastLoginAt; }
|
}
|
||||||
get updatedAt(): Date { return this._updatedAt; }
|
get avatarUrl(): string | null {
|
||||||
get isActive(): boolean { return this._status === AccountStatus.ACTIVE; }
|
return this._avatarUrl;
|
||||||
get isKYCVerified(): boolean { return this._kycStatus === KYCStatus.VERIFIED; }
|
}
|
||||||
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
get inviterSequence(): AccountSequence | null {
|
||||||
|
return this._inviterSequence;
|
||||||
|
}
|
||||||
|
get referralCode(): ReferralCode {
|
||||||
|
return this._referralCode;
|
||||||
|
}
|
||||||
|
get kycInfo(): KYCInfo | null {
|
||||||
|
return this._kycInfo;
|
||||||
|
}
|
||||||
|
get kycStatus(): KYCStatus {
|
||||||
|
return this._kycStatus;
|
||||||
|
}
|
||||||
|
get status(): AccountStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
get registeredAt(): Date {
|
||||||
|
return this._registeredAt;
|
||||||
|
}
|
||||||
|
get lastLoginAt(): Date | null {
|
||||||
|
return this._lastLoginAt;
|
||||||
|
}
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this._status === AccountStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
get isKYCVerified(): boolean {
|
||||||
|
return this._kycStatus === KYCStatus.VERIFIED;
|
||||||
|
}
|
||||||
|
get domainEvents(): DomainEvent[] {
|
||||||
|
return [...this._domainEvents];
|
||||||
|
}
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>,
|
userId: UserId,
|
||||||
phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null,
|
accountSequence: AccountSequence,
|
||||||
inviterSequence: AccountSequence | null, referralCode: ReferralCode,
|
devices: Map<string, DeviceInfo>,
|
||||||
walletAddresses: Map<ChainType, WalletAddress>, kycInfo: KYCInfo | null,
|
phoneNumber: PhoneNumber | null,
|
||||||
kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date,
|
nickname: string,
|
||||||
lastLoginAt: Date | null, updatedAt: Date,
|
avatarUrl: string | null,
|
||||||
|
inviterSequence: AccountSequence | null,
|
||||||
|
referralCode: ReferralCode,
|
||||||
|
walletAddresses: Map<ChainType, WalletAddress>,
|
||||||
|
kycInfo: KYCInfo | null,
|
||||||
|
kycStatus: KYCStatus,
|
||||||
|
status: AccountStatus,
|
||||||
|
registeredAt: Date,
|
||||||
|
lastLoginAt: Date | null,
|
||||||
|
updatedAt: Date,
|
||||||
) {
|
) {
|
||||||
this._userId = userId;
|
this._userId = userId;
|
||||||
this._accountSequence = accountSequence;
|
this._accountSequence = accountSequence;
|
||||||
|
|
@ -77,37 +133,56 @@ export class UserAccount {
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||||
inviterSequence: AccountSequence | null;
|
inviterSequence: AccountSequence | null;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatarSvg?: string;
|
avatarSvg?: string;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
const devices = new Map<string, DeviceInfo>();
|
const devices = new Map<string, DeviceInfo>();
|
||||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
devices.set(
|
||||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
params.initialDeviceId,
|
||||||
params.deviceInfo, // 传递完整的 JSON
|
new DeviceInfo(
|
||||||
));
|
params.initialDeviceId,
|
||||||
|
params.deviceName || '未命名设备',
|
||||||
|
new Date(),
|
||||||
|
new Date(),
|
||||||
|
params.deviceInfo, // 传递完整的 JSON
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||||
const nickname = params.nickname || `用户${params.accountSequence.dailySequence}`;
|
const nickname =
|
||||||
|
params.nickname || `用户${params.accountSequence.dailySequence}`;
|
||||||
const avatarUrl = params.avatarSvg || null;
|
const avatarUrl = params.avatarSvg || null;
|
||||||
|
|
||||||
const account = new UserAccount(
|
const account = new UserAccount(
|
||||||
UserId.create(0), params.accountSequence, devices, null,
|
UserId.create(0),
|
||||||
nickname, avatarUrl, params.inviterSequence,
|
params.accountSequence,
|
||||||
|
devices,
|
||||||
|
null,
|
||||||
|
nickname,
|
||||||
|
avatarUrl,
|
||||||
|
params.inviterSequence,
|
||||||
ReferralCode.generate(),
|
ReferralCode.generate(),
|
||||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
new Map(),
|
||||||
new Date(), null, new Date(),
|
null,
|
||||||
|
KYCStatus.NOT_VERIFIED,
|
||||||
|
AccountStatus.ACTIVE,
|
||||||
|
new Date(),
|
||||||
|
null,
|
||||||
|
new Date(),
|
||||||
);
|
);
|
||||||
|
|
||||||
account.addDomainEvent(new UserAccountAutoCreatedEvent({
|
account.addDomainEvent(
|
||||||
userId: account.userId.toString(),
|
new UserAccountAutoCreatedEvent({
|
||||||
accountSequence: params.accountSequence.value,
|
userId: account.userId.toString(),
|
||||||
referralCode: account._referralCode.value, // 用户的推荐码
|
accountSequence: params.accountSequence.value,
|
||||||
initialDeviceId: params.initialDeviceId,
|
referralCode: account._referralCode.value, // 用户的推荐码
|
||||||
inviterSequence: params.inviterSequence?.value || null,
|
initialDeviceId: params.initialDeviceId,
|
||||||
registeredAt: account._registeredAt,
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
}));
|
registeredAt: account._registeredAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
@ -117,50 +192,77 @@ export class UserAccount {
|
||||||
phoneNumber: PhoneNumber;
|
phoneNumber: PhoneNumber;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||||
inviterSequence: AccountSequence | null;
|
inviterSequence: AccountSequence | null;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
const devices = new Map<string, DeviceInfo>();
|
const devices = new Map<string, DeviceInfo>();
|
||||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
devices.set(
|
||||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
params.initialDeviceId,
|
||||||
params.deviceInfo,
|
new DeviceInfo(
|
||||||
));
|
params.initialDeviceId,
|
||||||
|
params.deviceName || '未命名设备',
|
||||||
|
new Date(),
|
||||||
|
new Date(),
|
||||||
|
params.deviceInfo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||||
const account = new UserAccount(
|
const account = new UserAccount(
|
||||||
UserId.create(0), params.accountSequence, devices, params.phoneNumber,
|
UserId.create(0),
|
||||||
`用户${params.accountSequence.dailySequence}`, null, params.inviterSequence,
|
params.accountSequence,
|
||||||
|
devices,
|
||||||
|
params.phoneNumber,
|
||||||
|
`用户${params.accountSequence.dailySequence}`,
|
||||||
|
null,
|
||||||
|
params.inviterSequence,
|
||||||
ReferralCode.generate(),
|
ReferralCode.generate(),
|
||||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
new Map(),
|
||||||
new Date(), null, new Date(),
|
null,
|
||||||
|
KYCStatus.NOT_VERIFIED,
|
||||||
|
AccountStatus.ACTIVE,
|
||||||
|
new Date(),
|
||||||
|
null,
|
||||||
|
new Date(),
|
||||||
);
|
);
|
||||||
|
|
||||||
account.addDomainEvent(new UserAccountCreatedEvent({
|
account.addDomainEvent(
|
||||||
userId: account.userId.toString(),
|
new UserAccountCreatedEvent({
|
||||||
accountSequence: params.accountSequence.value,
|
userId: account.userId.toString(),
|
||||||
referralCode: account._referralCode.value, // 用户的推荐码
|
accountSequence: params.accountSequence.value,
|
||||||
phoneNumber: params.phoneNumber.value,
|
referralCode: account._referralCode.value, // 用户的推荐码
|
||||||
initialDeviceId: params.initialDeviceId,
|
phoneNumber: params.phoneNumber.value,
|
||||||
inviterSequence: params.inviterSequence?.value || null,
|
initialDeviceId: params.initialDeviceId,
|
||||||
registeredAt: account._registeredAt,
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
}));
|
registeredAt: account._registeredAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
static reconstruct(params: {
|
static reconstruct(params: {
|
||||||
userId: string; accountSequence: string; devices: DeviceInfo[];
|
userId: string;
|
||||||
phoneNumber: string | null; nickname: string; avatarUrl: string | null;
|
accountSequence: string;
|
||||||
inviterSequence: string | null; referralCode: string;
|
devices: DeviceInfo[];
|
||||||
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null;
|
phoneNumber: string | null;
|
||||||
kycStatus: KYCStatus; status: AccountStatus;
|
nickname: string;
|
||||||
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
|
avatarUrl: string | null;
|
||||||
|
inviterSequence: string | null;
|
||||||
|
referralCode: string;
|
||||||
|
walletAddresses: WalletAddress[];
|
||||||
|
kycInfo: KYCInfo | null;
|
||||||
|
kycStatus: KYCStatus;
|
||||||
|
status: AccountStatus;
|
||||||
|
registeredAt: Date;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
const deviceMap = new Map<string, DeviceInfo>();
|
const deviceMap = new Map<string, DeviceInfo>();
|
||||||
params.devices.forEach(d => deviceMap.set(d.deviceId, d));
|
params.devices.forEach((d) => deviceMap.set(d.deviceId, d));
|
||||||
|
|
||||||
const walletMap = new Map<ChainType, WalletAddress>();
|
const walletMap = new Map<ChainType, WalletAddress>();
|
||||||
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w));
|
params.walletAddresses.forEach((w) => walletMap.set(w.chainType, w));
|
||||||
|
|
||||||
return new UserAccount(
|
return new UserAccount(
|
||||||
UserId.create(params.userId),
|
UserId.create(params.userId),
|
||||||
|
|
@ -169,7 +271,9 @@ export class UserAccount {
|
||||||
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
||||||
params.nickname,
|
params.nickname,
|
||||||
params.avatarUrl,
|
params.avatarUrl,
|
||||||
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null,
|
params.inviterSequence
|
||||||
|
? AccountSequence.create(params.inviterSequence)
|
||||||
|
: null,
|
||||||
ReferralCode.create(params.referralCode),
|
ReferralCode.create(params.referralCode),
|
||||||
walletMap,
|
walletMap,
|
||||||
params.kycInfo,
|
params.kycInfo,
|
||||||
|
|
@ -181,7 +285,11 @@ export class UserAccount {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addDevice(deviceId: string, deviceName?: string, deviceInfo?: Record<string, unknown>): void {
|
addDevice(
|
||||||
|
deviceId: string,
|
||||||
|
deviceName?: string,
|
||||||
|
deviceInfo?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
||||||
throw new DomainError('最多允许5个设备同时登录');
|
throw new DomainError('最多允许5个设备同时登录');
|
||||||
|
|
@ -193,15 +301,24 @@ export class UserAccount {
|
||||||
device.updateDeviceInfo(deviceInfo);
|
device.updateDeviceInfo(deviceInfo);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._devices.set(deviceId, new DeviceInfo(
|
this._devices.set(
|
||||||
deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo,
|
|
||||||
));
|
|
||||||
this.addDomainEvent(new DeviceAddedEvent({
|
|
||||||
userId: this.userId.toString(),
|
|
||||||
accountSequence: this.accountSequence.value,
|
|
||||||
deviceId,
|
deviceId,
|
||||||
deviceName: deviceName || '未命名设备',
|
new DeviceInfo(
|
||||||
}));
|
deviceId,
|
||||||
|
deviceName || '未命名设备',
|
||||||
|
new Date(),
|
||||||
|
new Date(),
|
||||||
|
deviceInfo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.addDomainEvent(
|
||||||
|
new DeviceAddedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
accountSequence: this.accountSequence.value,
|
||||||
|
deviceId,
|
||||||
|
deviceName: deviceName || '未命名设备',
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +329,9 @@ export class UserAccount {
|
||||||
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
||||||
this._devices.delete(deviceId);
|
this._devices.delete(deviceId);
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }));
|
this.addDomainEvent(
|
||||||
|
new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isDeviceAuthorized(deviceId: string): boolean {
|
isDeviceAuthorized(deviceId: string): boolean {
|
||||||
|
|
@ -235,54 +354,90 @@ export class UserAccount {
|
||||||
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
||||||
this._phoneNumber = phoneNumber;
|
this._phoneNumber = phoneNumber;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.toString(), phoneNumber: phoneNumber.value }));
|
this.addDomainEvent(
|
||||||
|
new PhoneNumberBoundEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
phoneNumber: phoneNumber.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bindWalletAddress(chainType: ChainType, address: string): void {
|
bindWalletAddress(chainType: ChainType, address: string): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
if (this._walletAddresses.has(chainType))
|
||||||
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
|
throw new DomainError(`已绑定${chainType}地址`);
|
||||||
|
const walletAddress = WalletAddress.create({
|
||||||
|
userId: this.userId,
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
});
|
||||||
this._walletAddresses.set(chainType, walletAddress);
|
this._walletAddresses.set(chainType, walletAddress);
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.toString(), chainType, address }));
|
this.addDomainEvent(
|
||||||
|
new WalletAddressBoundEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
for (const [chainType, wallet] of wallets) {
|
for (const [chainType, wallet] of wallets) {
|
||||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
if (this._walletAddresses.has(chainType))
|
||||||
|
throw new DomainError(`已绑定${chainType}地址`);
|
||||||
this._walletAddresses.set(chainType, wallet);
|
this._walletAddresses.set(chainType, wallet);
|
||||||
}
|
}
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({
|
this.addDomainEvent(
|
||||||
userId: this.userId.toString(),
|
new MultipleWalletAddressesBoundEvent({
|
||||||
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })),
|
userId: this.userId.toString(),
|
||||||
}));
|
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({
|
||||||
|
chainType,
|
||||||
|
address: wallet.address,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
submitKYC(kycInfo: KYCInfo): void {
|
submitKYC(kycInfo: KYCInfo): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交');
|
if (this._kycStatus === KYCStatus.VERIFIED)
|
||||||
|
throw new DomainError('已通过KYC认证,不可重复提交');
|
||||||
this._kycInfo = kycInfo;
|
this._kycInfo = kycInfo;
|
||||||
this._kycStatus = KYCStatus.PENDING;
|
this._kycStatus = KYCStatus.PENDING;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new KYCSubmittedEvent({
|
this.addDomainEvent(
|
||||||
userId: this.userId.toString(), realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber,
|
new KYCSubmittedEvent({
|
||||||
}));
|
userId: this.userId.toString(),
|
||||||
|
realName: kycInfo.realName,
|
||||||
|
idCardNumber: kycInfo.idCardNumber,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
approveKYC(): void {
|
approveKYC(): void {
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC');
|
if (this._kycStatus !== KYCStatus.PENDING)
|
||||||
|
throw new DomainError('只有待审核状态才能通过KYC');
|
||||||
this._kycStatus = KYCStatus.VERIFIED;
|
this._kycStatus = KYCStatus.VERIFIED;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.toString(), verifiedAt: new Date() }));
|
this.addDomainEvent(
|
||||||
|
new KYCVerifiedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectKYC(reason: string): void {
|
rejectKYC(reason: string): void {
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC');
|
if (this._kycStatus !== KYCStatus.PENDING)
|
||||||
|
throw new DomainError('只有待审核状态才能拒绝KYC');
|
||||||
this._kycStatus = KYCStatus.REJECTED;
|
this._kycStatus = KYCStatus.REJECTED;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.toString(), reason }));
|
this.addDomainEvent(
|
||||||
|
new KYCRejectedEvent({ userId: this.userId.toString(), reason }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordLogin(): void {
|
recordLogin(): void {
|
||||||
|
|
@ -292,23 +447,33 @@ export class UserAccount {
|
||||||
}
|
}
|
||||||
|
|
||||||
freeze(reason: string): void {
|
freeze(reason: string): void {
|
||||||
if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结');
|
if (this._status === AccountStatus.FROZEN)
|
||||||
|
throw new DomainError('账户已冻结');
|
||||||
this._status = AccountStatus.FROZEN;
|
this._status = AccountStatus.FROZEN;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }));
|
this.addDomainEvent(
|
||||||
|
new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
unfreeze(): void {
|
unfreeze(): void {
|
||||||
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结');
|
if (this._status !== AccountStatus.FROZEN)
|
||||||
|
throw new DomainError('账户未冻结');
|
||||||
this._status = AccountStatus.ACTIVE;
|
this._status = AccountStatus.ACTIVE;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate(): void {
|
deactivate(): void {
|
||||||
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销');
|
if (this._status === AccountStatus.DEACTIVATED)
|
||||||
|
throw new DomainError('账户已注销');
|
||||||
this._status = AccountStatus.DEACTIVATED;
|
this._status = AccountStatus.DEACTIVATED;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.toString(), deactivatedAt: new Date() }));
|
this.addDomainEvent(
|
||||||
|
new UserAccountDeactivatedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
||||||
|
|
@ -320,7 +485,8 @@ export class UserAccount {
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureActive(): void {
|
private ensureActive(): void {
|
||||||
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
|
if (this._status !== AccountStatus.ACTIVE)
|
||||||
|
throw new DomainError('账户已冻结或注销');
|
||||||
}
|
}
|
||||||
|
|
||||||
private addDomainEvent(event: DomainEvent): void {
|
private addDomainEvent(event: DomainEvent): void {
|
||||||
|
|
@ -339,7 +505,9 @@ export class UserAccount {
|
||||||
*/
|
*/
|
||||||
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
||||||
// 获取第一个设备的信息
|
// 获取第一个设备的信息
|
||||||
const firstDevice = this._devices.values().next().value as DeviceInfo | undefined;
|
const firstDevice = this._devices.values().next().value as
|
||||||
|
| DeviceInfo
|
||||||
|
| undefined;
|
||||||
|
|
||||||
return new UserAccountCreatedEvent({
|
return new UserAccountCreatedEvent({
|
||||||
userId: this._userId.toString(),
|
userId: this._userId.toString(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AccountSequenceGeneratorService, UserValidatorService } from './services';
|
import {
|
||||||
|
AccountSequenceGeneratorService,
|
||||||
|
UserValidatorService,
|
||||||
|
} from './services';
|
||||||
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
||||||
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
||||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||||
|
|
|
||||||
|
|
@ -24,21 +24,39 @@ export class WalletAddress {
|
||||||
private readonly _userId: UserId;
|
private readonly _userId: UserId;
|
||||||
private readonly _chainType: ChainType;
|
private readonly _chainType: ChainType;
|
||||||
private readonly _address: string;
|
private readonly _address: string;
|
||||||
private readonly _publicKey: string; // MPC 公钥
|
private readonly _publicKey: string; // MPC 公钥
|
||||||
private readonly _addressDigest: string; // 地址摘要
|
private readonly _addressDigest: string; // 地址摘要
|
||||||
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
||||||
private _status: AddressStatus;
|
private _status: AddressStatus;
|
||||||
private readonly _boundAt: Date;
|
private readonly _boundAt: Date;
|
||||||
|
|
||||||
get addressId(): AddressId { return this._addressId; }
|
get addressId(): AddressId {
|
||||||
get userId(): UserId { return this._userId; }
|
return this._addressId;
|
||||||
get chainType(): ChainType { return this._chainType; }
|
}
|
||||||
get address(): string { return this._address; }
|
get userId(): UserId {
|
||||||
get publicKey(): string { return this._publicKey; }
|
return this._userId;
|
||||||
get addressDigest(): string { return this._addressDigest; }
|
}
|
||||||
get mpcSignature(): MpcSignature { return this._mpcSignature; }
|
get chainType(): ChainType {
|
||||||
get status(): AddressStatus { return this._status; }
|
return this._chainType;
|
||||||
get boundAt(): Date { return this._boundAt; }
|
}
|
||||||
|
get address(): string {
|
||||||
|
return this._address;
|
||||||
|
}
|
||||||
|
get publicKey(): string {
|
||||||
|
return this._publicKey;
|
||||||
|
}
|
||||||
|
get addressDigest(): string {
|
||||||
|
return this._addressDigest;
|
||||||
|
}
|
||||||
|
get mpcSignature(): MpcSignature {
|
||||||
|
return this._mpcSignature;
|
||||||
|
}
|
||||||
|
get status(): AddressStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
get boundAt(): Date {
|
||||||
|
return this._boundAt;
|
||||||
|
}
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
addressId: AddressId,
|
addressId: AddressId,
|
||||||
|
|
@ -101,7 +119,7 @@ export class WalletAddress {
|
||||||
address: string;
|
address: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
addressDigest: string;
|
addressDigest: string;
|
||||||
mpcSignature: string; // 64 bytes hex
|
mpcSignature: string; // 64 bytes hex
|
||||||
status: AddressStatus;
|
status: AddressStatus;
|
||||||
boundAt: Date;
|
boundAt: Date;
|
||||||
}): WalletAddress {
|
}): WalletAddress {
|
||||||
|
|
@ -153,10 +171,19 @@ export class WalletAddress {
|
||||||
for (const v of [27, 28]) {
|
for (const v of [27, 28]) {
|
||||||
try {
|
try {
|
||||||
const sig = ethers.Signature.from({ r, s, v });
|
const sig = ethers.Signature.from({ r, s, v });
|
||||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
digestBytes,
|
||||||
|
sig,
|
||||||
|
);
|
||||||
|
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||||
|
recoveredPubKey,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
if (compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase()) {
|
if (
|
||||||
|
compressedRecovered.slice(2).toLowerCase() ===
|
||||||
|
this._publicKey.toLowerCase()
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -194,7 +221,7 @@ export class WalletAddress {
|
||||||
userId: UserId;
|
userId: UserId;
|
||||||
chainType: ChainType;
|
chainType: ChainType;
|
||||||
address: string;
|
address: string;
|
||||||
publicKey?: string; // 公钥
|
publicKey?: string; // 公钥
|
||||||
}): WalletAddress {
|
}): WalletAddress {
|
||||||
if (!this.validateAddress(params.chainType, params.address)) {
|
if (!this.validateAddress(params.chainType, params.address)) {
|
||||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||||
|
|
@ -206,7 +233,7 @@ export class WalletAddress {
|
||||||
params.address,
|
params.address,
|
||||||
params.publicKey || '',
|
params.publicKey || '',
|
||||||
'',
|
'',
|
||||||
'', // empty signature
|
'', // empty signature
|
||||||
AddressStatus.ACTIVE,
|
AddressStatus.ACTIVE,
|
||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
|
|
@ -229,20 +256,27 @@ export class WalletAddress {
|
||||||
address,
|
address,
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
'', // empty signature
|
'', // empty signature
|
||||||
AddressStatus.ACTIVE,
|
AddressStatus.ACTIVE,
|
||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
private static deriveAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
mnemonic: Mnemonic,
|
||||||
|
): string {
|
||||||
const seed = mnemonic.toSeed();
|
const seed = mnemonic.toSeed();
|
||||||
const config = CHAIN_CONFIG[chainType];
|
const config = CHAIN_CONFIG[chainType];
|
||||||
|
|
||||||
switch (chainType) {
|
switch (chainType) {
|
||||||
case ChainType.KAVA:
|
case ChainType.KAVA:
|
||||||
case ChainType.DST:
|
case ChainType.DST:
|
||||||
return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix);
|
return this.deriveCosmosAddress(
|
||||||
|
Buffer.from(seed),
|
||||||
|
config.derivationPath,
|
||||||
|
config.prefix,
|
||||||
|
);
|
||||||
case ChainType.BSC:
|
case ChainType.BSC:
|
||||||
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
||||||
default:
|
default:
|
||||||
|
|
@ -250,7 +284,11 @@ export class WalletAddress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string {
|
private static deriveCosmosAddress(
|
||||||
|
seed: Buffer,
|
||||||
|
path: string,
|
||||||
|
prefix: string,
|
||||||
|
): string {
|
||||||
const hdkey = HDKey.fromMasterSeed(seed);
|
const hdkey = HDKey.fromMasterSeed(seed);
|
||||||
const childKey = hdkey.derive(path);
|
const childKey = hdkey.derive(path);
|
||||||
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
||||||
|
|
@ -270,7 +308,10 @@ export class WalletAddress {
|
||||||
return wallet.address;
|
return wallet.address;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static validateAddress(chainType: ChainType, address: string): boolean {
|
private static validateAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): boolean {
|
||||||
switch (chainType) {
|
switch (chainType) {
|
||||||
case ChainType.KAVA:
|
case ChainType.KAVA:
|
||||||
case ChainType.BSC:
|
case ChainType.BSC:
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ export class UserAccountAutoCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -33,11 +33,11 @@ export class UserAccountCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -53,7 +53,7 @@ export class DeviceAddedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
},
|
},
|
||||||
|
|
@ -77,7 +77,9 @@ export class DeviceRemovedEvent extends DomainEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
constructor(
|
||||||
|
public readonly payload: { userId: string; phoneNumber: string },
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +89,13 @@ export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WalletAddressBoundEvent extends DomainEvent {
|
export class WalletAddressBoundEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; chainType: string; address: string }) {
|
constructor(
|
||||||
|
public readonly payload: {
|
||||||
|
userId: string;
|
||||||
|
chainType: string;
|
||||||
|
address: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +120,13 @@ export class MultipleWalletAddressesBoundEvent extends DomainEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KYCSubmittedEvent extends DomainEvent {
|
export class KYCSubmittedEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) {
|
constructor(
|
||||||
|
public readonly payload: {
|
||||||
|
userId: string;
|
||||||
|
realName: string;
|
||||||
|
idCardNumber: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +166,9 @@ export class UserAccountFrozenEvent extends DomainEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAccountDeactivatedEvent extends DomainEvent {
|
export class UserAccountDeactivatedEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) {
|
constructor(
|
||||||
|
public readonly payload: { userId: string; deactivatedAt: Date },
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +217,7 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
username: string;
|
username: string;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
totalParties: number;
|
totalParties: number;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { DomainEvent } from './index';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
constructor(
|
||||||
|
public readonly payload: { userId: string; phoneNumber: string },
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import {
|
import {
|
||||||
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus,
|
UserId,
|
||||||
|
AccountSequence,
|
||||||
|
PhoneNumber,
|
||||||
|
ReferralCode,
|
||||||
|
ChainType,
|
||||||
|
AccountStatus,
|
||||||
|
KYCStatus,
|
||||||
} from '@/domain/value-objects';
|
} from '@/domain/value-objects';
|
||||||
|
|
||||||
export interface Pagination {
|
export interface Pagination {
|
||||||
|
|
@ -35,18 +41,32 @@ export interface UserAccountRepository {
|
||||||
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
||||||
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
||||||
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
||||||
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>;
|
findByWalletAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<UserAccount | null>;
|
||||||
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
||||||
getNextAccountSequence(): Promise<AccountSequence>;
|
getNextAccountSequence(): Promise<AccountSequence>;
|
||||||
findUsers(
|
findUsers(
|
||||||
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string },
|
filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
|
kycStatus?: KYCStatus;
|
||||||
|
keyword?: string;
|
||||||
|
},
|
||||||
pagination?: Pagination,
|
pagination?: Pagination,
|
||||||
): Promise<UserAccount[]>;
|
): Promise<UserAccount[]>;
|
||||||
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
|
countUsers(filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
|
kycStatus?: KYCStatus;
|
||||||
|
}): Promise<number>;
|
||||||
|
|
||||||
// 推荐相关
|
// 推荐相关
|
||||||
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>;
|
findByInviterSequence(
|
||||||
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>;
|
inviterSequence: AccountSequence,
|
||||||
|
): Promise<UserAccount[]>;
|
||||||
|
createReferralLink(
|
||||||
|
params: CreateReferralLinkParams,
|
||||||
|
): Promise<ReferralLinkData>;
|
||||||
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { AccountSequence } from '@/domain/value-objects';
|
import { AccountSequence } from '@/domain/value-objects';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
|
import {
|
||||||
|
AccountSequence,
|
||||||
|
PhoneNumber,
|
||||||
|
ReferralCode,
|
||||||
|
ChainType,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
|
|
||||||
// ============ ValidationResult ============
|
// ============ ValidationResult ============
|
||||||
export class ValidationResult {
|
export class ValidationResult {
|
||||||
|
|
@ -39,7 +47,9 @@ export class UserValidatorService {
|
||||||
private readonly repository: UserAccountRepository,
|
private readonly repository: UserAccountRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
async validatePhoneNumber(
|
||||||
|
phoneNumber: PhoneNumber,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
|
|
@ -53,15 +63,24 @@ export class UserValidatorService {
|
||||||
// return ValidationResult.success();
|
// return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
async validateReferralCode(
|
||||||
|
referralCode: ReferralCode,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
if (!inviter.isActive)
|
||||||
|
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
async validateWalletAddress(
|
||||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const existing = await this.repository.findByWalletAddress(
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
);
|
||||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||||
|
|
||||||
export class ValidationResult {
|
export class ValidationResult {
|
||||||
|
|
@ -24,7 +27,9 @@ export class UserValidatorService {
|
||||||
private readonly repository: UserAccountRepository,
|
private readonly repository: UserAccountRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
async validatePhoneNumber(
|
||||||
|
phoneNumber: PhoneNumber,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
|
|
@ -38,15 +43,24 @@ export class UserValidatorService {
|
||||||
// return ValidationResult.success();
|
// return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
async validateReferralCode(
|
||||||
|
referralCode: ReferralCode,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
if (!inviter.isActive)
|
||||||
|
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
async validateWalletAddress(
|
||||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const existing = await this.repository.findByWalletAddress(
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
);
|
||||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export class AccountSequence {
|
||||||
|
|
||||||
constructor(public readonly value: string) {
|
constructor(public readonly value: string) {
|
||||||
if (!AccountSequence.PATTERN.test(value)) {
|
if (!AccountSequence.PATTERN.test(value)) {
|
||||||
throw new DomainError(`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`);
|
throw new DomainError(
|
||||||
|
`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export class DeviceInfo {
|
||||||
public readonly deviceName: string,
|
public readonly deviceName: string,
|
||||||
public readonly addedAt: Date,
|
public readonly addedAt: Date,
|
||||||
lastActiveAt: Date,
|
lastActiveAt: Date,
|
||||||
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
||||||
) {
|
) {
|
||||||
this._lastActiveAt = lastActiveAt;
|
this._lastActiveAt = lastActiveAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
import {
|
||||||
|
createHash,
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
} from 'crypto';
|
||||||
import * as bip39 from '@scure/bip39';
|
import * as bip39 from '@scure/bip39';
|
||||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
|
|
||||||
|
|
@ -144,7 +150,9 @@ export class DeviceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
get deviceModel(): string | undefined {
|
get deviceModel(): string | undefined {
|
||||||
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined;
|
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get osVersion(): string | undefined {
|
get osVersion(): string | undefined {
|
||||||
|
|
@ -188,13 +196,27 @@ export class KYCInfo {
|
||||||
if (!realName || realName.length < 2) {
|
if (!realName || realName.length < 2) {
|
||||||
throw new DomainError('真实姓名不合法');
|
throw new DomainError('真实姓名不合法');
|
||||||
}
|
}
|
||||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
if (
|
||||||
|
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
|
idCardNumber,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new DomainError('身份证号格式错误');
|
throw new DomainError('身份证号格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
static create(params: {
|
||||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
realName: string;
|
||||||
|
idCardNumber: string;
|
||||||
|
idCardFrontUrl: string;
|
||||||
|
idCardBackUrl: string;
|
||||||
|
}): KYCInfo {
|
||||||
|
return new KYCInfo(
|
||||||
|
params.realName,
|
||||||
|
params.idCardNumber,
|
||||||
|
params.idCardFrontUrl,
|
||||||
|
params.idCardBackUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedIdCardNumber(): string {
|
maskedIdCardNumber(): string {
|
||||||
|
|
@ -255,7 +277,11 @@ export class MnemonicEncryption {
|
||||||
static decrypt(encryptedData: string, key: string): string {
|
static decrypt(encryptedData: string, key: string): string {
|
||||||
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
|
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
|
||||||
const derivedKey = this.deriveKey(key);
|
const derivedKey = this.deriveKey(key);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex'));
|
const decipher = createDecipheriv(
|
||||||
|
'aes-256-gcm',
|
||||||
|
derivedKey,
|
||||||
|
Buffer.from(iv, 'hex'),
|
||||||
|
);
|
||||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,27 @@ export class KYCInfo {
|
||||||
if (!realName || realName.length < 2) {
|
if (!realName || realName.length < 2) {
|
||||||
throw new DomainError('真实姓名不合法');
|
throw new DomainError('真实姓名不合法');
|
||||||
}
|
}
|
||||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
if (
|
||||||
|
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
|
idCardNumber,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new DomainError('身份证号格式错误');
|
throw new DomainError('身份证号格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
static create(params: {
|
||||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
realName: string;
|
||||||
|
idCardNumber: string;
|
||||||
|
idCardFrontUrl: string;
|
||||||
|
idCardBackUrl: string;
|
||||||
|
}): KYCInfo {
|
||||||
|
return new KYCInfo(
|
||||||
|
params.realName,
|
||||||
|
params.idCardNumber,
|
||||||
|
params.idCardFrontUrl,
|
||||||
|
params.idCardBackUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedIdCardNumber(): string {
|
maskedIdCardNumber(): string {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ describe('Mnemonic ValueObject', () => {
|
||||||
|
|
||||||
const words = mnemonic.getWords();
|
const words = mnemonic.getWords();
|
||||||
expect(words).toHaveLength(12);
|
expect(words).toHaveLength(12);
|
||||||
expect(words.every(word => word.length > 0)).toBe(true);
|
expect(words.every((word) => word.length > 0)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('生成的助记词应该能转换为 seed', () => {
|
it('生成的助记词应该能转换为 seed', () => {
|
||||||
|
|
@ -33,7 +33,8 @@ describe('Mnemonic ValueObject', () => {
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('应该接受有效的助记词字符串', () => {
|
it('应该接受有效的助记词字符串', () => {
|
||||||
const validMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
const validMnemonic =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
const mnemonic = Mnemonic.create(validMnemonic);
|
const mnemonic = Mnemonic.create(validMnemonic);
|
||||||
|
|
||||||
expect(mnemonic.value).toBe(validMnemonic);
|
expect(mnemonic.value).toBe(validMnemonic);
|
||||||
|
|
@ -54,7 +55,8 @@ describe('Mnemonic ValueObject', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该拒绝非英文单词', () => {
|
it('应该拒绝非英文单词', () => {
|
||||||
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
const invalidMnemonic =
|
||||||
|
'中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
Mnemonic.create(invalidMnemonic);
|
Mnemonic.create(invalidMnemonic);
|
||||||
|
|
@ -74,7 +76,8 @@ describe('Mnemonic ValueObject', () => {
|
||||||
|
|
||||||
describe('toSeed', () => {
|
describe('toSeed', () => {
|
||||||
it('相同的助记词应该生成相同的 seed', () => {
|
it('相同的助记词应该生成相同的 seed', () => {
|
||||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
const mnemonicStr =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||||
|
|
||||||
|
|
@ -97,7 +100,8 @@ describe('Mnemonic ValueObject', () => {
|
||||||
|
|
||||||
describe('equals', () => {
|
describe('equals', () => {
|
||||||
it('相同的助记词应该相等', () => {
|
it('相同的助记词应该相等', () => {
|
||||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
const mnemonicStr =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('PhoneNumber ValueObject', () => {
|
||||||
'19900003333',
|
'19900003333',
|
||||||
];
|
];
|
||||||
|
|
||||||
validPhones.forEach(phone => {
|
validPhones.forEach((phone) => {
|
||||||
const phoneNumber = PhoneNumber.create(phone);
|
const phoneNumber = PhoneNumber.create(phone);
|
||||||
expect(phoneNumber.value).toBe(phone);
|
expect(phoneNumber.value).toBe(phone);
|
||||||
});
|
});
|
||||||
|
|
@ -20,15 +20,15 @@ describe('PhoneNumber ValueObject', () => {
|
||||||
|
|
||||||
it('应该拒绝无效的手机号格式', () => {
|
it('应该拒绝无效的手机号格式', () => {
|
||||||
const invalidPhones = [
|
const invalidPhones = [
|
||||||
'12800138000', // 不是1开头
|
'12800138000', // 不是1开头
|
||||||
'1380013800', // 少于11位
|
'1380013800', // 少于11位
|
||||||
'138001380000', // 多于11位
|
'138001380000', // 多于11位
|
||||||
'10800138000', // 第二位不是3-9
|
'10800138000', // 第二位不是3-9
|
||||||
'abcdefghijk', // 非数字
|
'abcdefghijk', // 非数字
|
||||||
'', // 空字符串
|
'', // 空字符串
|
||||||
];
|
];
|
||||||
|
|
||||||
invalidPhones.forEach(phone => {
|
invalidPhones.forEach((phone) => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
PhoneNumber.create(phone);
|
PhoneNumber.create(phone);
|
||||||
}).toThrow(DomainError);
|
}).toThrow(DomainError);
|
||||||
|
|
@ -42,7 +42,7 @@ describe('PhoneNumber ValueObject', () => {
|
||||||
'+8613800138000',
|
'+8613800138000',
|
||||||
];
|
];
|
||||||
|
|
||||||
invalidPhones.forEach(phone => {
|
invalidPhones.forEach((phone) => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
PhoneNumber.create(phone);
|
PhoneNumber.create(phone);
|
||||||
}).toThrow(DomainError);
|
}).toThrow(DomainError);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export interface VerifyMnemonicResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyMnemonicByAccountParams {
|
export interface VerifyMnemonicByAccountParams {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,8 +60,12 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 验证助记词是否匹配指定的钱包地址
|
* 验证助记词是否匹配指定的钱包地址
|
||||||
*/
|
*/
|
||||||
async verifyMnemonic(params: VerifyMnemonicParams): Promise<VerifyMnemonicResult> {
|
async verifyMnemonic(
|
||||||
this.logger.log(`Verifying mnemonic against ${params.expectedAddresses.length} addresses`);
|
params: VerifyMnemonicParams,
|
||||||
|
): Promise<VerifyMnemonicResult> {
|
||||||
|
this.logger.log(
|
||||||
|
`Verifying mnemonic against ${params.expectedAddresses.length} addresses`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
|
|
@ -78,7 +82,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
this.logger.log(
|
||||||
|
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to verify mnemonic', error);
|
this.logger.error('Failed to verify mnemonic', error);
|
||||||
|
|
@ -89,7 +95,9 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 通过账户序列号验证助记词(用于账户恢复)
|
* 通过账户序列号验证助记词(用于账户恢复)
|
||||||
*/
|
*/
|
||||||
async verifyMnemonicByAccount(params: VerifyMnemonicByAccountParams): Promise<VerifyMnemonicHashResult> {
|
async verifyMnemonicByAccount(
|
||||||
|
params: VerifyMnemonicByAccountParams,
|
||||||
|
): Promise<VerifyMnemonicHashResult> {
|
||||||
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
|
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,7 +115,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
this.logger.log(
|
||||||
|
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to verify mnemonic', error);
|
this.logger.error('Failed to verify mnemonic', error);
|
||||||
|
|
@ -133,7 +143,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Derived ${response.data.addresses.length} addresses from mnemonic`);
|
this.logger.log(
|
||||||
|
`Derived ${response.data.addresses.length} addresses from mnemonic`,
|
||||||
|
);
|
||||||
return response.data.addresses;
|
return response.data.addresses;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to derive addresses from mnemonic', error);
|
this.logger.error('Failed to derive addresses from mnemonic', error);
|
||||||
|
|
@ -145,7 +157,9 @@ export class BlockchainClientService {
|
||||||
* 标记助记词已备份
|
* 标记助记词已备份
|
||||||
*/
|
*/
|
||||||
async markMnemonicBackedUp(accountSequence: string): Promise<void> {
|
async markMnemonicBackedUp(accountSequence: string): Promise<void> {
|
||||||
this.logger.log(`Marking mnemonic as backed up for account ${accountSequence}`);
|
this.logger.log(
|
||||||
|
`Marking mnemonic as backed up for account ${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(
|
await firstValueFrom(
|
||||||
|
|
@ -159,7 +173,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic marked as backed up for account ${accountSequence}`);
|
this.logger.log(
|
||||||
|
`Mnemonic marked as backed up for account ${accountSequence}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to mark mnemonic as backed up', error);
|
this.logger.error('Failed to mark mnemonic as backed up', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -169,8 +185,13 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 挂失助记词
|
* 挂失助记词
|
||||||
*/
|
*/
|
||||||
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> {
|
async revokeMnemonic(
|
||||||
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`);
|
accountSequence: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
this.logger.log(
|
||||||
|
`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
|
|
@ -184,7 +205,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic revoke result: success=${response.data.success}`);
|
this.logger.log(
|
||||||
|
`Mnemonic revoke result: success=${response.data.success}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to revoke mnemonic', error);
|
this.logger.error('Failed to revoke mnemonic', error);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ import { HttpService } from '@nestjs/axios';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
import { EventPublisherService, IDENTITY_TOPICS } from '../../kafka/event-publisher.service';
|
import {
|
||||||
|
EventPublisherService,
|
||||||
|
IDENTITY_TOPICS,
|
||||||
|
} from '../../kafka/event-publisher.service';
|
||||||
import {
|
import {
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
KeygenCompletedPayload,
|
KeygenCompletedPayload,
|
||||||
|
|
@ -38,34 +41,34 @@ export const MPC_REQUEST_TOPICS = {
|
||||||
|
|
||||||
export interface KeygenRequest {
|
export interface KeygenRequest {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
username: string; // 用户名 (自动递增ID)
|
username: string; // 用户名 (自动递增ID)
|
||||||
threshold: number; // t in t-of-n (默认 1, 即 2-of-3)
|
threshold: number; // t in t-of-n (默认 1, 即 2-of-3)
|
||||||
totalParties: number; // n in t-of-n (默认 3)
|
totalParties: number; // n in t-of-n (默认 3)
|
||||||
requireDelegate: boolean; // 是否需要 delegate party
|
requireDelegate: boolean; // 是否需要 delegate party
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeygenResult {
|
export interface KeygenResult {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
publicKey: string; // 压缩格式公钥 (33 bytes hex)
|
publicKey: string; // 压缩格式公钥 (33 bytes hex)
|
||||||
delegateShare: DelegateShare; // delegate share (用户分片)
|
delegateShare: DelegateShare; // delegate share (用户分片)
|
||||||
serverParties: string[]; // 服务器 party IDs
|
serverParties: string[]; // 服务器 party IDs
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DelegateShare {
|
export interface DelegateShare {
|
||||||
partyId: string;
|
partyId: string;
|
||||||
partyIndex: number;
|
partyIndex: number;
|
||||||
encryptedShare: string; // 加密的分片数据 (hex)
|
encryptedShare: string; // 加密的分片数据 (hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningRequest {
|
export interface SigningRequest {
|
||||||
username: string;
|
username: string;
|
||||||
messageHash: string; // 32 bytes hex
|
messageHash: string; // 32 bytes hex
|
||||||
userShare?: string; // 如果账户有 delegate share,需要传入用户分片
|
userShare?: string; // 如果账户有 delegate share,需要传入用户分片
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningResult {
|
export interface SigningResult {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
signature: string; // 64 bytes hex (R + S)
|
signature: string; // 64 bytes hex (R + S)
|
||||||
messageHash: string;
|
messageHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,13 +99,19 @@ export interface AsyncSigningResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 结果回调类型
|
// 结果回调类型
|
||||||
export type KeygenResultCallback = (result: KeygenResult | null, error?: string) => Promise<void>;
|
export type KeygenResultCallback = (
|
||||||
export type SigningResultCallback = (result: SigningResult | null, error?: string) => Promise<void>;
|
result: KeygenResult | null,
|
||||||
|
error?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
export type SigningResultCallback = (
|
||||||
|
result: SigningResult | null,
|
||||||
|
error?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MpcClientService implements OnModuleInit {
|
export class MpcClientService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(MpcClientService.name);
|
private readonly logger = new Logger(MpcClientService.name);
|
||||||
private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL
|
private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL
|
||||||
private readonly mpcMode: string;
|
private readonly mpcMode: string;
|
||||||
private readonly useEventDriven: boolean;
|
private readonly useEventDriven: boolean;
|
||||||
private readonly pollIntervalMs = 2000;
|
private readonly pollIntervalMs = 2000;
|
||||||
|
|
@ -110,7 +119,8 @@ export class MpcClientService implements OnModuleInit {
|
||||||
|
|
||||||
// 待处理的 keygen/signing 请求回调
|
// 待处理的 keygen/signing 请求回调
|
||||||
private pendingKeygenCallbacks: Map<string, KeygenResultCallback> = new Map();
|
private pendingKeygenCallbacks: Map<string, KeygenResultCallback> = new Map();
|
||||||
private pendingSigningCallbacks: Map<string, SigningResultCallback> = new Map();
|
private pendingSigningCallbacks: Map<string, SigningResultCallback> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
|
|
@ -119,15 +129,23 @@ export class MpcClientService implements OnModuleInit {
|
||||||
private readonly mpcEventConsumer: MpcEventConsumerService,
|
private readonly mpcEventConsumer: MpcEventConsumerService,
|
||||||
) {
|
) {
|
||||||
// 连接 mpc-service (NestJS)
|
// 连接 mpc-service (NestJS)
|
||||||
this.mpcServiceUrl = this.configService.get<string>('MPC_SERVICE_URL', 'http://localhost:3001');
|
this.mpcServiceUrl = this.configService.get<string>(
|
||||||
|
'MPC_SERVICE_URL',
|
||||||
|
'http://localhost:3001',
|
||||||
|
);
|
||||||
this.mpcMode = this.configService.get<string>('MPC_MODE', 'local');
|
this.mpcMode = this.configService.get<string>('MPC_MODE', 'local');
|
||||||
this.useEventDriven = this.configService.get<string>('MPC_USE_EVENT_DRIVEN', 'true') === 'true';
|
this.useEventDriven =
|
||||||
|
this.configService.get<string>('MPC_USE_EVENT_DRIVEN', 'true') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// 注册 MPC 事件处理器
|
// 注册 MPC 事件处理器
|
||||||
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
|
this.mpcEventConsumer.onKeygenCompleted(
|
||||||
this.mpcEventConsumer.onSigningCompleted(this.handleSigningCompleted.bind(this));
|
this.handleKeygenCompleted.bind(this),
|
||||||
|
);
|
||||||
|
this.mpcEventConsumer.onSigningCompleted(
|
||||||
|
this.handleSigningCompleted.bind(this),
|
||||||
|
);
|
||||||
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
||||||
this.logger.log('MPC event handlers registered');
|
this.logger.log('MPC event handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +164,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
): Promise<AsyncKeygenResponse> {
|
): Promise<AsyncKeygenResponse> {
|
||||||
const sessionId = this.generateSessionId();
|
const sessionId = this.generateSessionId();
|
||||||
|
|
||||||
this.logger.log(`Requesting async keygen: userId=${request.userId}, sessionId=${sessionId}`);
|
this.logger.log(
|
||||||
|
`Requesting async keygen: userId=${request.userId}, sessionId=${sessionId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 如果是本地模式,直接执行并回调
|
// 如果是本地模式,直接执行并回调
|
||||||
if (this.mpcMode === 'local') {
|
if (this.mpcMode === 'local') {
|
||||||
|
|
@ -189,7 +209,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
): Promise<AsyncSigningResponse> {
|
): Promise<AsyncSigningResponse> {
|
||||||
const sessionId = this.generateSessionId();
|
const sessionId = this.generateSessionId();
|
||||||
|
|
||||||
this.logger.log(`Requesting async signing: userId=${request.userId}, sessionId=${sessionId}`);
|
this.logger.log(
|
||||||
|
`Requesting async signing: userId=${request.userId}, sessionId=${sessionId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 如果是本地模式,直接执行并回调
|
// 如果是本地模式,直接执行并回调
|
||||||
if (this.mpcMode === 'local') {
|
if (this.mpcMode === 'local') {
|
||||||
|
|
@ -226,7 +248,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
// 事件处理器 - 处理 MPC 完成事件
|
// 事件处理器 - 处理 MPC 完成事件
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
|
private async handleKeygenCompleted(
|
||||||
|
payload: KeygenCompletedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const sessionId = payload.sessionId;
|
const sessionId = payload.sessionId;
|
||||||
const callback = this.pendingKeygenCallbacks.get(sessionId);
|
const callback = this.pendingKeygenCallbacks.get(sessionId);
|
||||||
|
|
||||||
|
|
@ -246,14 +270,19 @@ export class MpcClientService implements OnModuleInit {
|
||||||
};
|
};
|
||||||
await callback(result);
|
await callback(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Keygen callback error: sessionId=${sessionId}`, error);
|
this.logger.error(
|
||||||
|
`Keygen callback error: sessionId=${sessionId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingKeygenCallbacks.delete(sessionId);
|
this.pendingKeygenCallbacks.delete(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSigningCompleted(payload: SigningCompletedPayload): Promise<void> {
|
private async handleSigningCompleted(
|
||||||
|
payload: SigningCompletedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const sessionId = payload.sessionId;
|
const sessionId = payload.sessionId;
|
||||||
const callback = this.pendingSigningCallbacks.get(sessionId);
|
const callback = this.pendingSigningCallbacks.get(sessionId);
|
||||||
|
|
||||||
|
|
@ -268,18 +297,25 @@ export class MpcClientService implements OnModuleInit {
|
||||||
};
|
};
|
||||||
await callback(result);
|
await callback(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Signing callback error: sessionId=${sessionId}`, error);
|
this.logger.error(
|
||||||
|
`Signing callback error: sessionId=${sessionId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingSigningCallbacks.delete(sessionId);
|
this.pendingSigningCallbacks.delete(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
|
private async handleSessionFailed(
|
||||||
|
payload: SessionFailedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const sessionId = payload.sessionId;
|
const sessionId = payload.sessionId;
|
||||||
const sessionType = payload.sessionType;
|
const sessionType = payload.sessionType;
|
||||||
|
|
||||||
this.logger.warn(`Session failed event received: sessionId=${sessionId}, type=${sessionType}`);
|
this.logger.warn(
|
||||||
|
`Session failed event received: sessionId=${sessionId}, type=${sessionType}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (sessionType === 'keygen') {
|
if (sessionType === 'keygen') {
|
||||||
const callback = this.pendingKeygenCallbacks.get(sessionId);
|
const callback = this.pendingKeygenCallbacks.get(sessionId);
|
||||||
|
|
@ -318,7 +354,10 @@ export class MpcClientService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
await callback(null, error instanceof Error ? error.message : 'Unknown error');
|
await callback(
|
||||||
|
null,
|
||||||
|
error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +378,10 @@ export class MpcClientService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
await callback(null, error instanceof Error ? error.message : 'Unknown error');
|
await callback(
|
||||||
|
null,
|
||||||
|
error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,7 +408,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
* 调用路径: identity-service → mpc-service → mpc-system
|
* 调用路径: identity-service → mpc-service → mpc-system
|
||||||
*/
|
*/
|
||||||
async executeKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
async executeKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
||||||
this.logger.log(`Starting MPC keygen: username=${request.username}, t=${request.threshold}, n=${request.totalParties}`);
|
this.logger.log(
|
||||||
|
`Starting MPC keygen: username=${request.username}, t=${request.threshold}, n=${request.totalParties}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 开发模式使用本地模拟
|
// 开发模式使用本地模拟
|
||||||
if (this.mpcMode === 'local') {
|
if (this.mpcMode === 'local') {
|
||||||
|
|
@ -403,7 +447,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
const sessionResult = await this.pollKeygenStatus(sessionId);
|
const sessionResult = await this.pollKeygenStatus(sessionId);
|
||||||
|
|
||||||
if (sessionResult.status !== 'completed') {
|
if (sessionResult.status !== 'completed') {
|
||||||
throw new Error(`Keygen session failed with status: ${sessionResult.status}`);
|
throw new Error(
|
||||||
|
`Keygen session failed with status: ${sessionResult.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Keygen completed: publicKey=${sessionResult.publicKey}`);
|
this.logger.log(`Keygen completed: publicKey=${sessionResult.publicKey}`);
|
||||||
|
|
@ -415,7 +461,10 @@ export class MpcClientService implements OnModuleInit {
|
||||||
serverParties: sessionResult.serverParties,
|
serverParties: sessionResult.serverParties,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`MPC keygen failed: username=${request.username}`, error);
|
this.logger.error(
|
||||||
|
`MPC keygen failed: username=${request.username}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
throw new Error(`MPC keygen failed: ${error.message}`);
|
throw new Error(`MPC keygen failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -442,12 +491,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
encryptedShare: string;
|
encryptedShare: string;
|
||||||
};
|
};
|
||||||
serverParties?: string[];
|
serverParties?: string[];
|
||||||
}>(
|
}>(`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`, {
|
||||||
`${this.mpcServiceUrl}/api/v1/mpc/keygen/${sessionId}/status`,
|
timeout: 10000,
|
||||||
{
|
}),
|
||||||
timeout: 10000,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
@ -457,7 +503,11 @@ export class MpcClientService implements OnModuleInit {
|
||||||
return {
|
return {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
publicKey: data.publicKey || '',
|
publicKey: data.publicKey || '',
|
||||||
delegateShare: data.delegateShare || { partyId: '', partyIndex: -1, encryptedShare: '' },
|
delegateShare: data.delegateShare || {
|
||||||
|
partyId: '',
|
||||||
|
partyIndex: -1,
|
||||||
|
encryptedShare: '',
|
||||||
|
},
|
||||||
serverParties: data.serverParties || [],
|
serverParties: data.serverParties || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -478,7 +528,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Session ${sessionId} timed out after ${this.maxPollAttempts * this.pollIntervalMs}ms`);
|
throw new Error(
|
||||||
|
`Session ${sessionId} timed out after ${this.maxPollAttempts * this.pollIntervalMs}ms`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -487,7 +539,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
* 调用路径: identity-service → mpc-service → mpc-system
|
* 调用路径: identity-service → mpc-service → mpc-system
|
||||||
*/
|
*/
|
||||||
async executeSigning(request: SigningRequest): Promise<SigningResult> {
|
async executeSigning(request: SigningRequest): Promise<SigningResult> {
|
||||||
this.logger.log(`Starting MPC signing: username=${request.username}, messageHash=${request.messageHash}`);
|
this.logger.log(
|
||||||
|
`Starting MPC signing: username=${request.username}, messageHash=${request.messageHash}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 开发模式使用本地模拟
|
// 开发模式使用本地模拟
|
||||||
if (this.mpcMode === 'local') {
|
if (this.mpcMode === 'local') {
|
||||||
|
|
@ -523,7 +577,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
const signResult = await this.pollSigningStatus(sessionId);
|
const signResult = await this.pollSigningStatus(sessionId);
|
||||||
|
|
||||||
if (signResult.status !== 'completed') {
|
if (signResult.status !== 'completed') {
|
||||||
throw new Error(`Signing session failed with status: ${signResult.status}`);
|
throw new Error(
|
||||||
|
`Signing session failed with status: ${signResult.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -532,7 +588,10 @@ export class MpcClientService implements OnModuleInit {
|
||||||
messageHash: request.messageHash,
|
messageHash: request.messageHash,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`MPC signing failed: username=${request.username}`, error);
|
this.logger.error(
|
||||||
|
`MPC signing failed: username=${request.username}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
throw new Error(`MPC signing failed: ${error.message}`);
|
throw new Error(`MPC signing failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -551,12 +610,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
status: string;
|
status: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
}>(
|
}>(`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`, {
|
||||||
`${this.mpcServiceUrl}/api/v1/mpc/sign/${sessionId}/status`,
|
timeout: 10000,
|
||||||
{
|
}),
|
||||||
timeout: 10000,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
@ -586,14 +642,16 @@ export class MpcClientService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
private sleep(ms: number): Promise<void> {
|
private sleep(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行本地模拟的 MPC keygen (用于开发测试)
|
* 执行本地模拟的 MPC keygen (用于开发测试)
|
||||||
*/
|
*/
|
||||||
async executeLocalKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
async executeLocalKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
||||||
this.logger.log(`Starting LOCAL MPC keygen (test mode): username=${request.username}`);
|
this.logger.log(
|
||||||
|
`Starting LOCAL MPC keygen (test mode): username=${request.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
const { ethers } = await import('ethers');
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
|
@ -602,13 +660,19 @@ export class MpcClientService implements OnModuleInit {
|
||||||
const publicKey = wallet.publicKey;
|
const publicKey = wallet.publicKey;
|
||||||
|
|
||||||
// 压缩公钥 (33 bytes)
|
// 压缩公钥 (33 bytes)
|
||||||
const compressedPubKey = ethers.SigningKey.computePublicKey(publicKey, true);
|
const compressedPubKey = ethers.SigningKey.computePublicKey(
|
||||||
|
publicKey,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// 模拟 delegate share
|
// 模拟 delegate share
|
||||||
const delegateShare: DelegateShare = {
|
const delegateShare: DelegateShare = {
|
||||||
partyId: 'delegate-party',
|
partyId: 'delegate-party',
|
||||||
partyIndex: 2,
|
partyIndex: 2,
|
||||||
encryptedShare: this.encryptShareData(wallet.privateKey, request.username),
|
encryptedShare: this.encryptShareData(
|
||||||
|
wallet.privateKey,
|
||||||
|
request.username,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -623,7 +687,9 @@ export class MpcClientService implements OnModuleInit {
|
||||||
* 执行本地模拟的 MPC 签名 (用于开发测试)
|
* 执行本地模拟的 MPC 签名 (用于开发测试)
|
||||||
*/
|
*/
|
||||||
async executeLocalSigning(request: SigningRequest): Promise<SigningResult> {
|
async executeLocalSigning(request: SigningRequest): Promise<SigningResult> {
|
||||||
this.logger.log(`Starting LOCAL MPC signing (test mode): username=${request.username}`);
|
this.logger.log(
|
||||||
|
`Starting LOCAL MPC signing (test mode): username=${request.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
const { ethers } = await import('ethers');
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { MpcClientService } from './mpc-client.service';
|
||||||
|
|
||||||
export interface MpcWalletGenerationParams {
|
export interface MpcWalletGenerationParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string; // 用户名 (用于 MPC keygen)
|
username: string; // 用户名 (用于 MPC keygen)
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,15 +22,15 @@ export interface ChainWalletInfo {
|
||||||
address: string;
|
address: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
addressDigest: string;
|
addressDigest: string;
|
||||||
signature: string; // 64 bytes hex (R + S)
|
signature: string; // 64 bytes hex (R + S)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpcWalletGenerationResult {
|
export interface MpcWalletGenerationResult {
|
||||||
publicKey: string; // MPC 公钥
|
publicKey: string; // MPC 公钥
|
||||||
delegateShare: string; // delegate share (加密的用户分片)
|
delegateShare: string; // delegate share (加密的用户分片)
|
||||||
serverParties: string[]; // 服务器 party IDs
|
serverParties: string[]; // 服务器 party IDs
|
||||||
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
||||||
sessionId: string; // MPC 会话ID
|
sessionId: string; // MPC 会话ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -53,15 +53,13 @@ export class MpcWalletService {
|
||||||
},
|
},
|
||||||
DST: {
|
DST: {
|
||||||
name: 'Durian Star Token',
|
name: 'Durian Star Token',
|
||||||
prefix: 'dst', // Cosmos Bech32 前缀
|
prefix: 'dst', // Cosmos Bech32 前缀
|
||||||
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
||||||
addressType: 'cosmos' as const,
|
addressType: 'cosmos' as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly mpcClient: MpcClientService) {}
|
||||||
private readonly mpcClient: MpcClientService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 MPC 2-of-3 生成三链钱包
|
* 使用 MPC 2-of-3 生成三链钱包
|
||||||
|
|
@ -73,22 +71,30 @@ export class MpcWalletService {
|
||||||
* 4. 使用 MPC 签名对摘要进行签名
|
* 4. 使用 MPC 签名对摘要进行签名
|
||||||
* 5. 返回完整的钱包信息
|
* 5. 返回完整的钱包信息
|
||||||
*/
|
*/
|
||||||
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> {
|
async generateMpcWallet(
|
||||||
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`);
|
params: MpcWalletGenerationParams,
|
||||||
|
): Promise<MpcWalletGenerationResult> {
|
||||||
|
this.logger.log(
|
||||||
|
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 1: 生成 MPC 密钥
|
// Step 1: 生成 MPC 密钥
|
||||||
const keygenResult = await this.mpcClient.executeKeygen({
|
const keygenResult = await this.mpcClient.executeKeygen({
|
||||||
sessionId: this.mpcClient.generateSessionId(),
|
sessionId: this.mpcClient.generateSessionId(),
|
||||||
username: params.username,
|
username: params.username,
|
||||||
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
||||||
totalParties: 3,
|
totalParties: 3,
|
||||||
requireDelegate: true,
|
requireDelegate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`);
|
this.logger.log(
|
||||||
|
`MPC keygen completed: publicKey=${keygenResult.publicKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 2: 从公钥派生三条链的地址
|
// Step 2: 从公钥派生三条链的地址
|
||||||
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey);
|
const walletAddresses = await this.deriveChainAddresses(
|
||||||
|
keygenResult.publicKey,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: 计算地址摘要
|
// Step 3: 计算地址摘要
|
||||||
const addressDigest = this.computeAddressDigest(walletAddresses);
|
const addressDigest = this.computeAddressDigest(walletAddresses);
|
||||||
|
|
@ -99,7 +105,9 @@ export class MpcWalletService {
|
||||||
messageHash: addressDigest,
|
messageHash: addressDigest,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`);
|
this.logger.log(
|
||||||
|
`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 5: 构建钱包信息
|
// Step 5: 构建钱包信息
|
||||||
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
||||||
|
|
@ -140,7 +148,9 @@ export class MpcWalletService {
|
||||||
|
|
||||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
||||||
if (signature.length !== 128) {
|
if (signature.length !== 128) {
|
||||||
this.logger.error(`Invalid signature length: ${signature.length}, expected 128`);
|
this.logger.error(
|
||||||
|
`Invalid signature length: ${signature.length}, expected 128`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,10 +165,19 @@ export class MpcWalletService {
|
||||||
for (const v of [27, 28]) {
|
for (const v of [27, 28]) {
|
||||||
try {
|
try {
|
||||||
const sig = ethers.Signature.from({ r, s, v });
|
const sig = ethers.Signature.from({ r, s, v });
|
||||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
digestBytes,
|
||||||
|
sig,
|
||||||
|
);
|
||||||
|
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||||
|
recoveredPubKey,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
if (compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase()) {
|
if (
|
||||||
|
compressedRecovered.slice(2).toLowerCase() ===
|
||||||
|
publicKey.toLowerCase()
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -179,13 +198,18 @@ export class MpcWalletService {
|
||||||
* - BSC/KAVA: EVM 地址 (keccak256)
|
* - BSC/KAVA: EVM 地址 (keccak256)
|
||||||
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
||||||
*/
|
*/
|
||||||
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> {
|
private async deriveChainAddresses(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<{ chainType: string; address: string }[]> {
|
||||||
const { ethers } = await import('ethers');
|
const { ethers } = await import('ethers');
|
||||||
const { bech32 } = await import('bech32');
|
const { bech32 } = await import('bech32');
|
||||||
|
|
||||||
// MPC 公钥 (压缩格式,33 bytes)
|
// MPC 公钥 (压缩格式,33 bytes)
|
||||||
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
||||||
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex');
|
const compressedPubKeyBytes = Buffer.from(
|
||||||
|
pubKeyHex.replace('0x', ''),
|
||||||
|
'hex',
|
||||||
|
);
|
||||||
|
|
||||||
// 解压公钥 (如果是压缩格式)
|
// 解压公钥 (如果是压缩格式)
|
||||||
let uncompressedPubKey: string;
|
let uncompressedPubKey: string;
|
||||||
|
|
@ -204,9 +228,14 @@ export class MpcWalletService {
|
||||||
|
|
||||||
// ===== Cosmos 地址派生 (DST) =====
|
// ===== Cosmos 地址派生 (DST) =====
|
||||||
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
||||||
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest();
|
const sha256Hash = createHash('sha256')
|
||||||
|
.update(compressedPubKeyBytes)
|
||||||
|
.digest();
|
||||||
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
||||||
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash));
|
const dstAddress = bech32.encode(
|
||||||
|
this.chainConfigs.DST.prefix,
|
||||||
|
bech32.toWords(ripemd160Hash),
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ chainType: 'BSC', address: evmAddress },
|
{ chainType: 'BSC', address: evmAddress },
|
||||||
|
|
@ -220,14 +249,18 @@ export class MpcWalletService {
|
||||||
*
|
*
|
||||||
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
||||||
*/
|
*/
|
||||||
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string {
|
private computeAddressDigest(
|
||||||
|
addresses: { chainType: string; address: string }[],
|
||||||
|
): string {
|
||||||
// 按链类型排序以确保一致性
|
// 按链类型排序以确保一致性
|
||||||
const sortedAddresses = [...addresses].sort((a, b) =>
|
const sortedAddresses = [...addresses].sort((a, b) =>
|
||||||
a.chainType.localeCompare(b.chainType),
|
a.chainType.localeCompare(b.chainType),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 拼接地址
|
// 拼接地址
|
||||||
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join('');
|
const concatenated = sortedAddresses
|
||||||
|
.map((a) => a.address.toLowerCase())
|
||||||
|
.join('');
|
||||||
|
|
||||||
// 计算 SHA256 摘要
|
// 计算 SHA256 摘要
|
||||||
return createHash('sha256').update(concatenated).digest('hex');
|
return createHash('sha256').update(concatenated).digest('hex');
|
||||||
|
|
@ -236,7 +269,10 @@ export class MpcWalletService {
|
||||||
/**
|
/**
|
||||||
* 计算单个地址的摘要
|
* 计算单个地址的摘要
|
||||||
*/
|
*/
|
||||||
private computeSingleAddressDigest(address: string, chainType: string): string {
|
private computeSingleAddressDigest(
|
||||||
|
address: string,
|
||||||
|
chainType: string,
|
||||||
|
): string {
|
||||||
const message = `${chainType}:${address.toLowerCase()}`;
|
const message = `${chainType}:${address.toLowerCase()}`;
|
||||||
return createHash('sha256').update(message).digest('hex');
|
return createHash('sha256').update(message).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,14 @@ export class SmsService implements OnModuleInit {
|
||||||
const smsConfig = this.configService.get('smsConfig') || {};
|
const smsConfig = this.configService.get('smsConfig') || {};
|
||||||
const aliyunConfig = smsConfig.aliyun || {};
|
const aliyunConfig = smsConfig.aliyun || {};
|
||||||
|
|
||||||
this.signName = aliyunConfig.signName || this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
this.signName =
|
||||||
this.templateCode = aliyunConfig.templateCode || this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
aliyunConfig.signName ||
|
||||||
this.enabled = smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
||||||
|
this.templateCode =
|
||||||
|
aliyunConfig.templateCode ||
|
||||||
|
this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
||||||
|
this.enabled =
|
||||||
|
smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
|
@ -35,8 +40,13 @@ export class SmsService implements OnModuleInit {
|
||||||
|
|
||||||
private async initClient(): Promise<void> {
|
private async initClient(): Promise<void> {
|
||||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
const accessKeySecret = this.configService.get<string>(
|
||||||
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
|
'ALIYUN_ACCESS_KEY_SECRET',
|
||||||
|
);
|
||||||
|
const endpoint = this.configService.get<string>(
|
||||||
|
'ALIYUN_SMS_ENDPOINT',
|
||||||
|
'dysmsapi.aliyuncs.com',
|
||||||
|
);
|
||||||
|
|
||||||
if (!accessKeyId || !accessKeySecret) {
|
if (!accessKeyId || !accessKeySecret) {
|
||||||
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
||||||
|
|
@ -64,15 +74,22 @@ export class SmsService implements OnModuleInit {
|
||||||
* @param code 验证码
|
* @param code 验证码
|
||||||
* @returns 发送结果
|
* @returns 发送结果
|
||||||
*/
|
*/
|
||||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
async sendVerificationCode(
|
||||||
|
phoneNumber: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
// 标准化手机号(去除 +86 前缀)
|
// 标准化手机号(去除 +86 前缀)
|
||||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
this.logger.log(
|
||||||
|
`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 开发环境或未启用时,使用模拟模式
|
// 开发环境或未启用时,使用模拟模式
|
||||||
if (!this.enabled || !this.client) {
|
if (!this.enabled || !this.client) {
|
||||||
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
this.logger.warn(
|
||||||
|
`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
requestId: 'mock-request-id',
|
requestId: 'mock-request-id',
|
||||||
|
|
@ -92,9 +109,12 @@ export class SmsService implements OnModuleInit {
|
||||||
|
|
||||||
const runtime = new $Util.RuntimeOptions({
|
const runtime = new $Util.RuntimeOptions({
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
});
|
});
|
||||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
const response = await this.client.sendSmsWithOptions(
|
||||||
|
sendSmsRequest,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
const body = response.body;
|
const body = response.body;
|
||||||
const result: SmsSendResult = {
|
const result: SmsSendResult = {
|
||||||
|
|
@ -106,9 +126,13 @@ export class SmsService implements OnModuleInit {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
|
this.logger.log(
|
||||||
|
`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`);
|
this.logger.error(
|
||||||
|
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -148,7 +172,9 @@ export class SmsService implements OnModuleInit {
|
||||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
if (!this.enabled || !this.client) {
|
if (!this.enabled || !this.client) {
|
||||||
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
this.logger.warn(
|
||||||
|
`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
requestId: 'mock-request-id',
|
requestId: 'mock-request-id',
|
||||||
|
|
@ -167,9 +193,12 @@ export class SmsService implements OnModuleInit {
|
||||||
|
|
||||||
const runtime = new $Util.RuntimeOptions({
|
const runtime = new $Util.RuntimeOptions({
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
});
|
});
|
||||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
const response = await this.client.sendSmsWithOptions(
|
||||||
|
sendSmsRequest,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
const body = response.body;
|
const body = response.body;
|
||||||
return {
|
return {
|
||||||
|
|
@ -207,19 +236,23 @@ export class SmsService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({
|
const querySendDetailsRequest =
|
||||||
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
new $Dysmsapi20170525.QuerySendDetailsRequest({
|
||||||
bizId,
|
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
||||||
sendDate,
|
bizId,
|
||||||
pageSize: 10,
|
sendDate,
|
||||||
currentPage: 1,
|
pageSize: 10,
|
||||||
});
|
currentPage: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const runtime = new $Util.RuntimeOptions({
|
const runtime = new $Util.RuntimeOptions({
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
});
|
});
|
||||||
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime);
|
const response = await this.client.querySendDetailsWithOptions(
|
||||||
|
querySendDetailsRequest,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -251,6 +284,10 @@ export class SmsService implements OnModuleInit {
|
||||||
if (phoneNumber.length < 7) {
|
if (phoneNumber.length < 7) {
|
||||||
return phoneNumber;
|
return phoneNumber;
|
||||||
}
|
}
|
||||||
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4);
|
return (
|
||||||
|
phoneNumber.substring(0, 3) +
|
||||||
|
'****' +
|
||||||
|
phoneNumber.substring(phoneNumber.length - 4)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,30 @@ export class StorageService implements OnModuleInit {
|
||||||
private publicUrl: string;
|
private publicUrl: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost');
|
const endpoint = this.configService.get<string>(
|
||||||
|
'MINIO_ENDPOINT',
|
||||||
|
'localhost',
|
||||||
|
);
|
||||||
const port = this.configService.get<number>('MINIO_PORT', 9000);
|
const port = this.configService.get<number>('MINIO_PORT', 9000);
|
||||||
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
const useSSL =
|
||||||
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
|
this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||||
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
|
const accessKey = this.configService.get<string>(
|
||||||
|
'MINIO_ACCESS_KEY',
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
const secretKey = this.configService.get<string>(
|
||||||
|
'MINIO_SECRET_KEY',
|
||||||
|
'minio_secret_password',
|
||||||
|
);
|
||||||
|
|
||||||
this.bucketAvatars = this.configService.get<string>('MINIO_BUCKET_AVATARS', 'avatars');
|
this.bucketAvatars = this.configService.get<string>(
|
||||||
this.publicUrl = this.configService.get<string>('MINIO_PUBLIC_URL', 'http://localhost:9000');
|
'MINIO_BUCKET_AVATARS',
|
||||||
|
'avatars',
|
||||||
|
);
|
||||||
|
this.publicUrl = this.configService.get<string>(
|
||||||
|
'MINIO_PUBLIC_URL',
|
||||||
|
'http://localhost:9000',
|
||||||
|
);
|
||||||
|
|
||||||
this.client = new Minio.Client({
|
this.client = new Minio.Client({
|
||||||
endPoint: endpoint,
|
endPoint: endpoint,
|
||||||
|
|
@ -83,7 +99,9 @@ export class StorageService implements OnModuleInit {
|
||||||
this.logger.log(`Bucket exists: ${bucketName}`);
|
this.logger.log(`Bucket exists: ${bucketName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`);
|
this.logger.error(
|
||||||
|
`Failed to ensure bucket ${bucketName}: ${error.message}`,
|
||||||
|
);
|
||||||
// 不抛出异常,允许服务启动(MinIO可能暂时不可用)
|
// 不抛出异常,允许服务启动(MinIO可能暂时不可用)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +174,7 @@ export class StorageService implements OnModuleInit {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
// URL格式: http://host/bucket/key
|
// URL格式: http://host/bucket/key
|
||||||
const pathParts = urlObj.pathname.split('/').filter(p => p);
|
const pathParts = urlObj.pathname.split('/').filter((p) => p);
|
||||||
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
|
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
|
||||||
return pathParts.slice(1).join('/');
|
return pathParts.slice(1).join('/');
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +202,13 @@ export class StorageService implements OnModuleInit {
|
||||||
* 验证图片类型
|
* 验证图片类型
|
||||||
*/
|
*/
|
||||||
isValidImageType(contentType: string): boolean {
|
isValidImageType(contentType: string): boolean {
|
||||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
const validTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
return validTypes.includes(contentType);
|
return validTypes.includes(contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
|
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
|
|
@ -22,15 +27,17 @@ export interface WalletAddressCreatedPayload {
|
||||||
address: string;
|
address: string;
|
||||||
}[];
|
}[];
|
||||||
// 恢复助记词相关
|
// 恢复助记词相关
|
||||||
mnemonic?: string; // 12词助记词 (明文)
|
mnemonic?: string; // 12词助记词 (明文)
|
||||||
encryptedMnemonic?: string; // 加密的助记词
|
encryptedMnemonic?: string; // 加密的助记词
|
||||||
mnemonicHash?: string; // 助记词哈希
|
mnemonicHash?: string; // 助记词哈希
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
export class BlockchainEventConsumerService
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
private readonly logger = new Logger(BlockchainEventConsumerService.name);
|
private readonly logger = new Logger(BlockchainEventConsumerService.name);
|
||||||
private kafka: Kafka;
|
private kafka: Kafka;
|
||||||
private consumer: Consumer;
|
private consumer: Consumer;
|
||||||
|
|
@ -41,15 +48,20 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
.get<string>('KAFKA_BROKERS')
|
||||||
|
?.split(',') || ['localhost:9092'];
|
||||||
|
const clientId =
|
||||||
|
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||||
const groupId = 'identity-service-blockchain-events';
|
const groupId = 'identity-service-blockchain-events';
|
||||||
|
|
||||||
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
|
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||||
this.kafka = new Kafka({
|
this.kafka = new Kafka({
|
||||||
|
|
@ -57,10 +69,10 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
brokers,
|
brokers,
|
||||||
logLevel: logLevel.WARN,
|
logLevel: logLevel.WARN,
|
||||||
retry: {
|
retry: {
|
||||||
initialRetryTime: 1000, // 1 秒
|
initialRetryTime: 1000, // 1 秒
|
||||||
maxRetryTime: 300000, // 最大 5 分钟
|
maxRetryTime: 300000, // 最大 5 分钟
|
||||||
retries: 15, // 最多 15 次
|
retries: 15, // 最多 15 次
|
||||||
multiplier: 2, // 指数退避因子
|
multiplier: 2, // 指数退避因子
|
||||||
restartOnFailure: async () => true,
|
restartOnFailure: async () => true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -75,16 +87,26 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
|
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
|
||||||
await this.consumer.connect();
|
await this.consumer.connect();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.logger.log(`[CONNECT] Blockchain Event Kafka consumer connected successfully`);
|
this.logger.log(
|
||||||
|
`[CONNECT] Blockchain Event Kafka consumer connected successfully`,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to blockchain topics
|
// Subscribe to blockchain topics
|
||||||
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false });
|
await this.consumer.subscribe({
|
||||||
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
topics: Object.values(BLOCKCHAIN_TOPICS),
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Start consuming
|
// Start consuming
|
||||||
await this.startConsuming();
|
await this.startConsuming();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to connect Blockchain Event Kafka consumer`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to connect Blockchain Event Kafka consumer`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,16 +120,24 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
/**
|
/**
|
||||||
* Register handler for wallet address created events
|
* Register handler for wallet address created events
|
||||||
*/
|
*/
|
||||||
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void {
|
onWalletAddressCreated(
|
||||||
|
handler: BlockchainEventHandler<WalletAddressCreatedPayload>,
|
||||||
|
): void {
|
||||||
this.walletAddressCreatedHandler = handler;
|
this.walletAddressCreatedHandler = handler;
|
||||||
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
|
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConsuming(): Promise<void> {
|
private async startConsuming(): Promise<void> {
|
||||||
await this.consumer.run({
|
await this.consumer.run({
|
||||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
eachMessage: async ({
|
||||||
|
topic,
|
||||||
|
partition,
|
||||||
|
message,
|
||||||
|
}: EachMessagePayload) => {
|
||||||
const offset = message.offset;
|
const offset = message.offset;
|
||||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = message.value?.toString();
|
const value = message.value?.toString();
|
||||||
|
|
@ -116,33 +146,53 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const payload = parsed.payload || parsed;
|
const payload = parsed.payload || parsed;
|
||||||
const eventType = parsed.eventType || 'unknown';
|
const eventType = parsed.eventType || 'unknown';
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
|
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
|
||||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle WalletAddressCreated events
|
// Handle WalletAddressCreated events
|
||||||
if (eventType === 'blockchain.wallet.address.created' || topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED) {
|
if (
|
||||||
|
eventType === 'blockchain.wallet.address.created' ||
|
||||||
|
topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED
|
||||||
|
) {
|
||||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
|
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
|
||||||
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
|
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
|
||||||
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
|
this.logger.log(
|
||||||
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`);
|
`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] addresses count: ${payload.addresses?.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (this.walletAddressCreatedHandler) {
|
if (this.walletAddressCreatedHandler) {
|
||||||
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload);
|
await this.walletAddressCreatedHandler(
|
||||||
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`);
|
payload as WalletAddressCreatedPayload,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] WalletAddressCreated handler completed successfully`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for WalletAddressCreated`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
|
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing blockchain event from ${topic}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Error processing blockchain event from ${topic}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ export class DeadLetterService {
|
||||||
const [total, pending, processed, byTopic] = await Promise.all([
|
const [total, pending, processed, byTopic] = await Promise.all([
|
||||||
this.prisma.deadLetterEvent.count(),
|
this.prisma.deadLetterEvent.count(),
|
||||||
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
||||||
this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }),
|
this.prisma.deadLetterEvent.count({
|
||||||
|
where: { processedAt: { not: null } },
|
||||||
|
}),
|
||||||
this.prisma.deadLetterEvent.groupBy({
|
this.prisma.deadLetterEvent.groupBy({
|
||||||
by: ['topic'],
|
by: ['topic'],
|
||||||
_count: true,
|
_count: true,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ export class EventConsumerController {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processKYCSubmitted(message.payload);
|
await this.processKYCSubmitted(message.payload);
|
||||||
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`);
|
this.logger.log(
|
||||||
|
`Successfully processed KYCSubmitted: ${message.eventId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to process KYCSubmitted: ${message.eventId}`,
|
`Failed to process KYCSubmitted: ${message.eventId}`,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||||
import { DomainEvent } from '@/domain/events';
|
import { DomainEvent } from '@/domain/events';
|
||||||
|
|
@ -48,8 +53,13 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
private producer: Producer;
|
private producer: Producer;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
|
.get<string>('KAFKA_BROKERS', 'localhost:9092')
|
||||||
|
.split(',');
|
||||||
|
const clientId = this.configService.get<string>(
|
||||||
|
'KAFKA_CLIENT_ID',
|
||||||
|
'identity-service',
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
|
|
@ -77,7 +87,10 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
async publish(event: DomainEvent): Promise<void>;
|
async publish(event: DomainEvent): Promise<void>;
|
||||||
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
||||||
async publish(eventOrTopic: DomainEvent | string, message?: DomainEventMessage): Promise<void> {
|
async publish(
|
||||||
|
eventOrTopic: DomainEvent | string,
|
||||||
|
message?: DomainEventMessage,
|
||||||
|
): Promise<void> {
|
||||||
if (typeof eventOrTopic === 'string') {
|
if (typeof eventOrTopic === 'string') {
|
||||||
// 直接发布到指定 topic (用于重试场景)
|
// 直接发布到指定 topic (用于重试场景)
|
||||||
const topic = eventOrTopic;
|
const topic = eventOrTopic;
|
||||||
|
|
@ -96,14 +109,18 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`);
|
this.logger.log(
|
||||||
|
`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 从领域事件发布
|
// 从领域事件发布
|
||||||
const event = eventOrTopic;
|
const event = eventOrTopic;
|
||||||
const topic = this.getTopicForEvent(event);
|
const topic = this.getTopicForEvent(event);
|
||||||
const payload = (event as any).payload;
|
const payload = (event as any).payload;
|
||||||
|
|
||||||
this.logger.log(`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`);
|
this.logger.log(
|
||||||
|
`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`,
|
||||||
|
);
|
||||||
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
||||||
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
||||||
|
|
||||||
|
|
@ -126,7 +143,9 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`[PUBLISH] Successfully published ${event.eventType} to ${topic}`);
|
this.logger.log(
|
||||||
|
`[PUBLISH] Successfully published ${event.eventType} to ${topic}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
* Updates user wallet addresses when keygen completes.
|
* Updates user wallet addresses when keygen completes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
|
|
@ -32,7 +37,7 @@ export interface KeygenCompletedPayload {
|
||||||
threshold: string;
|
threshold: string;
|
||||||
extraPayload?: {
|
extraPayload?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
username: string;
|
username: string;
|
||||||
delegateShare?: {
|
delegateShare?: {
|
||||||
partyId: string;
|
partyId: string;
|
||||||
|
|
@ -85,15 +90,20 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
.get<string>('KAFKA_BROKERS')
|
||||||
|
?.split(',') || ['localhost:9092'];
|
||||||
|
const clientId =
|
||||||
|
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||||
const groupId = 'identity-service-mpc-events';
|
const groupId = 'identity-service-mpc-events';
|
||||||
|
|
||||||
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
|
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||||
this.kafka = new Kafka({
|
this.kafka = new Kafka({
|
||||||
|
|
@ -101,10 +111,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
brokers,
|
brokers,
|
||||||
logLevel: logLevel.WARN,
|
logLevel: logLevel.WARN,
|
||||||
retry: {
|
retry: {
|
||||||
initialRetryTime: 1000, // 1 秒
|
initialRetryTime: 1000, // 1 秒
|
||||||
maxRetryTime: 300000, // 最大 5 分钟
|
maxRetryTime: 300000, // 最大 5 分钟
|
||||||
retries: 15, // 最多 15 次
|
retries: 15, // 最多 15 次
|
||||||
multiplier: 2, // 指数退避因子
|
multiplier: 2, // 指数退避因子
|
||||||
restartOnFailure: async () => true,
|
restartOnFailure: async () => true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -119,16 +129,26 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
||||||
await this.consumer.connect();
|
await this.consumer.connect();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
|
this.logger.log(
|
||||||
|
`[CONNECT] MPC Event Kafka consumer connected successfully`,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to MPC topics
|
// Subscribe to MPC topics
|
||||||
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
|
await this.consumer.subscribe({
|
||||||
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
|
topics: Object.values(MPC_TOPICS),
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Start consuming
|
// Start consuming
|
||||||
await this.startConsuming();
|
await this.startConsuming();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to connect MPC Event Kafka consumer`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,9 +189,15 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
private async startConsuming(): Promise<void> {
|
private async startConsuming(): Promise<void> {
|
||||||
await this.consumer.run({
|
await this.consumer.run({
|
||||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
eachMessage: async ({
|
||||||
|
topic,
|
||||||
|
partition,
|
||||||
|
message,
|
||||||
|
}: EachMessagePayload) => {
|
||||||
const offset = message.offset;
|
const offset = message.offset;
|
||||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = message.value?.toString();
|
const value = message.value?.toString();
|
||||||
|
|
@ -180,55 +206,83 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const payload = parsed.payload || parsed;
|
const payload = parsed.payload || parsed;
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`);
|
this.logger.log(
|
||||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
switch (topic) {
|
switch (topic) {
|
||||||
case MPC_TOPICS.KEYGEN_STARTED:
|
case MPC_TOPICS.KEYGEN_STARTED:
|
||||||
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
|
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
|
||||||
if (this.keygenStartedHandler) {
|
if (this.keygenStartedHandler) {
|
||||||
await this.keygenStartedHandler(payload as KeygenStartedPayload);
|
await this.keygenStartedHandler(
|
||||||
|
payload as KeygenStartedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
|
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for KeygenStarted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for KeygenStarted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.KEYGEN_COMPLETED:
|
case MPC_TOPICS.KEYGEN_COMPLETED:
|
||||||
this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
|
this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
|
||||||
this.logger.log(`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`);
|
this.logger.log(
|
||||||
|
`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`,
|
||||||
|
);
|
||||||
if (this.keygenCompletedHandler) {
|
if (this.keygenCompletedHandler) {
|
||||||
await this.keygenCompletedHandler(payload as KeygenCompletedPayload);
|
await this.keygenCompletedHandler(
|
||||||
|
payload as KeygenCompletedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
|
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for KeygenCompleted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.SIGNING_COMPLETED:
|
case MPC_TOPICS.SIGNING_COMPLETED:
|
||||||
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
|
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
|
||||||
if (this.signingCompletedHandler) {
|
if (this.signingCompletedHandler) {
|
||||||
await this.signingCompletedHandler(payload as SigningCompletedPayload);
|
await this.signingCompletedHandler(
|
||||||
|
payload as SigningCompletedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
|
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for SigningCompleted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for SigningCompleted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.SESSION_FAILED:
|
case MPC_TOPICS.SESSION_FAILED:
|
||||||
this.logger.log(`[HANDLE] Processing SessionFailed event`);
|
this.logger.log(`[HANDLE] Processing SessionFailed event`);
|
||||||
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`);
|
this.logger.log(
|
||||||
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`);
|
`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`,
|
||||||
|
);
|
||||||
if (this.sessionFailedHandler) {
|
if (this.sessionFailedHandler) {
|
||||||
await this.sessionFailedHandler(payload as SessionFailedPayload);
|
await this.sessionFailedHandler(
|
||||||
|
payload as SessionFailedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] SessionFailed handler completed`);
|
this.logger.log(`[HANDLE] SessionFailed handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for SessionFailed`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -236,7 +290,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
|
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing MPC event from ${topic}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Error processing MPC event from ${topic}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Producer, logLevel } from 'kafkajs';
|
import { Kafka, Producer, logLevel } from 'kafkajs';
|
||||||
import { OutboxRepository, OutboxEvent } from '../persistence/repositories/outbox.repository';
|
import {
|
||||||
|
OutboxRepository,
|
||||||
|
OutboxEvent,
|
||||||
|
} from '../persistence/repositories/outbox.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbox Publisher Service (B方案 - 消费方确认模式)
|
* Outbox Publisher Service (B方案 - 消费方确认模式)
|
||||||
|
|
@ -38,14 +46,31 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly outboxRepository: OutboxRepository,
|
private readonly outboxRepository: OutboxRepository,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.configService.get<number>('OUTBOX_POLL_INTERVAL_MS', 1000);
|
this.pollIntervalMs = this.configService.get<number>(
|
||||||
|
'OUTBOX_POLL_INTERVAL_MS',
|
||||||
|
1000,
|
||||||
|
);
|
||||||
this.batchSize = this.configService.get<number>('OUTBOX_BATCH_SIZE', 100);
|
this.batchSize = this.configService.get<number>('OUTBOX_BATCH_SIZE', 100);
|
||||||
this.cleanupIntervalMs = this.configService.get<number>('OUTBOX_CLEANUP_INTERVAL_MS', 3600000); // 1小时
|
this.cleanupIntervalMs = this.configService.get<number>(
|
||||||
this.confirmationTimeoutMinutes = this.configService.get<number>('OUTBOX_CONFIRMATION_TIMEOUT_MINUTES', 5);
|
'OUTBOX_CLEANUP_INTERVAL_MS',
|
||||||
this.timeoutCheckIntervalMs = this.configService.get<number>('OUTBOX_TIMEOUT_CHECK_INTERVAL_MS', 60000); // 1分钟
|
3600000,
|
||||||
|
); // 1小时
|
||||||
|
this.confirmationTimeoutMinutes = this.configService.get<number>(
|
||||||
|
'OUTBOX_CONFIRMATION_TIMEOUT_MINUTES',
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
this.timeoutCheckIntervalMs = this.configService.get<number>(
|
||||||
|
'OUTBOX_TIMEOUT_CHECK_INTERVAL_MS',
|
||||||
|
60000,
|
||||||
|
); // 1分钟
|
||||||
|
|
||||||
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
|
.get<string>('KAFKA_BROKERS', 'localhost:9092')
|
||||||
|
.split(',');
|
||||||
|
const clientId = this.configService.get<string>(
|
||||||
|
'KAFKA_CLIENT_ID',
|
||||||
|
'identity-service',
|
||||||
|
);
|
||||||
|
|
||||||
this.kafka = new Kafka({
|
this.kafka = new Kafka({
|
||||||
clientId: `${clientId}-outbox`,
|
clientId: `${clientId}-outbox`,
|
||||||
|
|
@ -56,8 +81,8 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[OUTBOX] OutboxPublisher (B方案) configured: ` +
|
`[OUTBOX] OutboxPublisher (B方案) configured: ` +
|
||||||
`pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` +
|
`pollInterval=${this.pollIntervalMs}ms, batchSize=${this.batchSize}, ` +
|
||||||
`confirmationTimeout=${this.confirmationTimeoutMinutes}min`,
|
`confirmationTimeout=${this.confirmationTimeoutMinutes}min`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +95,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.start();
|
this.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('[OUTBOX] Failed to connect to Kafka:', error);
|
this.logger.error('[OUTBOX] Failed to connect to Kafka:', error);
|
||||||
this.logger.warn('[OUTBOX] OutboxPublisher will not start - events will accumulate in outbox table');
|
this.logger.warn(
|
||||||
|
'[OUTBOX] OutboxPublisher will not start - events will accumulate in outbox table',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +130,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
// 启动超时检查任务(B方案核心)
|
// 启动超时检查任务(B方案核心)
|
||||||
this.timeoutCheckInterval = setInterval(() => {
|
this.timeoutCheckInterval = setInterval(() => {
|
||||||
this.checkConfirmationTimeouts().catch((err) => {
|
this.checkConfirmationTimeouts().catch((err) => {
|
||||||
this.logger.error('[OUTBOX] Error checking confirmation timeouts:', err);
|
this.logger.error(
|
||||||
|
'[OUTBOX] Error checking confirmation timeouts:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, this.timeoutCheckIntervalMs);
|
}, this.timeoutCheckIntervalMs);
|
||||||
|
|
||||||
|
|
@ -114,7 +144,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
});
|
});
|
||||||
}, this.cleanupIntervalMs);
|
}, this.cleanupIntervalMs);
|
||||||
|
|
||||||
this.logger.log('[OUTBOX] Outbox publisher started (B方案 - 消费方确认模式)');
|
this.logger.log(
|
||||||
|
'[OUTBOX] Outbox publisher started (B方案 - 消费方确认模式)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,10 +185,14 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 获取待发布事件
|
// 1. 获取待发布事件
|
||||||
const pendingEvents = await this.outboxRepository.findPendingEvents(this.batchSize);
|
const pendingEvents = await this.outboxRepository.findPendingEvents(
|
||||||
|
this.batchSize,
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 获取需要重试的事件
|
// 2. 获取需要重试的事件
|
||||||
const retryEvents = await this.outboxRepository.findEventsForRetry(Math.floor(this.batchSize / 2));
|
const retryEvents = await this.outboxRepository.findEventsForRetry(
|
||||||
|
Math.floor(this.batchSize / 2),
|
||||||
|
);
|
||||||
|
|
||||||
const allEvents = [...pendingEvents, ...retryEvents];
|
const allEvents = [...pendingEvents, ...retryEvents];
|
||||||
|
|
||||||
|
|
@ -164,7 +200,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`[OUTBOX] Processing ${allEvents.length} events (${pendingEvents.length} pending, ${retryEvents.length} retry)`);
|
this.logger.debug(
|
||||||
|
`[OUTBOX] Processing ${allEvents.length} events (${pendingEvents.length} pending, ${retryEvents.length} retry)`,
|
||||||
|
);
|
||||||
|
|
||||||
// 3. 逐个发布
|
// 3. 逐个发布
|
||||||
for (const event of allEvents) {
|
for (const event of allEvents) {
|
||||||
|
|
@ -183,7 +221,9 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
*/
|
*/
|
||||||
private async publishEvent(event: OutboxEvent): Promise<void> {
|
private async publishEvent(event: OutboxEvent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`[OUTBOX] Publishing event ${event.id} to topic ${event.topic}`);
|
this.logger.debug(
|
||||||
|
`[OUTBOX] Publishing event ${event.id} to topic ${event.topic}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 构造 Kafka 消息,包含 outboxId 用于确认
|
// 构造 Kafka 消息,包含 outboxId 用于确认
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -213,8 +253,11 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
`[OUTBOX] → Event ${event.id} sent to ${event.topic} (awaiting consumer confirmation)`,
|
`[OUTBOX] → Event ${event.id} sent to ${event.topic} (awaiting consumer confirmation)`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage =
|
||||||
this.logger.error(`[OUTBOX] ✗ Failed to publish event ${event.id}: ${errorMessage}`);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(
|
||||||
|
`[OUTBOX] ✗ Failed to publish event ${event.id}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 标记为失败并安排重试
|
// 标记为失败并安排重试
|
||||||
await this.outboxRepository.markAsFailed(event.id, errorMessage);
|
await this.outboxRepository.markAsFailed(event.id, errorMessage);
|
||||||
|
|
@ -252,7 +295,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('[OUTBOX] Error checking confirmation timeouts:', error);
|
this.logger.error(
|
||||||
|
'[OUTBOX] Error checking confirmation timeouts:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +306,10 @@ export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
* 清理旧事件
|
* 清理旧事件
|
||||||
*/
|
*/
|
||||||
private async cleanup(): Promise<void> {
|
private async cleanup(): Promise<void> {
|
||||||
const retentionDays = this.configService.get<number>('OUTBOX_RETENTION_DAYS', 7);
|
const retentionDays = this.configService.get<number>(
|
||||||
|
'OUTBOX_RETENTION_DAYS',
|
||||||
|
7,
|
||||||
|
);
|
||||||
await this.outboxRepository.cleanupOldEvents(retentionDays);
|
await this.outboxRepository.cleanupOldEvents(retentionDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Prisma Entity Types - 用于Mapper转换
|
// Prisma Entity Types - 用于Mapper转换
|
||||||
export interface UserAccountEntity {
|
export interface UserAccountEntity {
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
passwordHash: string | null; // bcrypt 哈希密码
|
passwordHash: string | null; // bcrypt 哈希密码
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
kycStatus: string;
|
kycStatus: string;
|
||||||
realName: string | null;
|
realName: string | null;
|
||||||
|
|
@ -27,7 +27,7 @@ export interface UserDeviceEntity {
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string | null;
|
deviceName: string | null;
|
||||||
deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON
|
deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON
|
||||||
// Hardware Info (冗余字段,便于查询)
|
// Hardware Info (冗余字段,便于查询)
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
deviceModel: string | null;
|
deviceModel: string | null;
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,15 @@ export function toMpcSignatureString(entity: WalletAddressEntity): string {
|
||||||
* 将应用层签名格式转换为数据库格式
|
* 将应用层签名格式转换为数据库格式
|
||||||
* 应用: string (64 bytes hex) -> 数据库: {r, s, v}
|
* 应用: string (64 bytes hex) -> 数据库: {r, s, v}
|
||||||
*/
|
*/
|
||||||
export function fromMpcSignatureString(signature: string): { r: string; s: string; v: number } {
|
export function fromMpcSignatureString(signature: string): {
|
||||||
|
r: string;
|
||||||
|
s: string;
|
||||||
|
v: number;
|
||||||
|
} {
|
||||||
// 签名格式: R (32 bytes = 64 hex) + S (32 bytes = 64 hex)
|
// 签名格式: R (32 bytes = 64 hex) + S (32 bytes = 64 hex)
|
||||||
return {
|
return {
|
||||||
r: signature.slice(0, 64) || '',
|
r: signature.slice(0, 64) || '',
|
||||||
s: signature.slice(64, 128) || '',
|
s: signature.slice(64, 128) || '',
|
||||||
v: 0, // 默认 v=0,实际验证时尝试 27 和 28
|
v: 0, // 默认 v=0,实际验证时尝试 27 和 28
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
|
import {
|
||||||
|
DeviceInfo,
|
||||||
|
KYCInfo,
|
||||||
|
KYCStatus,
|
||||||
|
AccountStatus,
|
||||||
|
ChainType,
|
||||||
|
AddressStatus,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
import { UserAccountEntity } from '../entities/user-account.entity';
|
import { UserAccountEntity } from '../entities/user-account.entity';
|
||||||
import { toMpcSignatureString } from '../entities/wallet-address.entity';
|
import { toMpcSignatureString } from '../entities/wallet-address.entity';
|
||||||
|
|
||||||
|
|
@ -27,7 +34,7 @@ export class UserAccountMapper {
|
||||||
address: w.address,
|
address: w.address,
|
||||||
publicKey: w.publicKey,
|
publicKey: w.publicKey,
|
||||||
addressDigest: w.addressDigest,
|
addressDigest: w.addressDigest,
|
||||||
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
|
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
|
||||||
status: w.status as AddressStatus,
|
status: w.status as AddressStatus,
|
||||||
boundAt: w.boundAt,
|
boundAt: w.boundAt,
|
||||||
}),
|
}),
|
||||||
|
|
@ -45,12 +52,12 @@ export class UserAccountMapper {
|
||||||
|
|
||||||
return UserAccount.reconstruct({
|
return UserAccount.reconstruct({
|
||||||
userId: entity.userId.toString(),
|
userId: entity.userId.toString(),
|
||||||
accountSequence: entity.accountSequence, // 现在是字符串类型
|
accountSequence: entity.accountSequence, // 现在是字符串类型
|
||||||
devices,
|
devices,
|
||||||
phoneNumber: entity.phoneNumber,
|
phoneNumber: entity.phoneNumber,
|
||||||
nickname: entity.nickname,
|
nickname: entity.nickname,
|
||||||
avatarUrl: entity.avatarUrl,
|
avatarUrl: entity.avatarUrl,
|
||||||
inviterSequence: entity.inviterSequence, // 现在是字符串类型
|
inviterSequence: entity.inviterSequence, // 现在是字符串类型
|
||||||
referralCode: entity.referralCode,
|
referralCode: entity.referralCode,
|
||||||
walletAddresses: wallets,
|
walletAddresses: wallets,
|
||||||
kycInfo,
|
kycInfo,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export enum OutboxStatus {
|
export enum OutboxStatus {
|
||||||
PENDING = 'PENDING', // 待发送
|
PENDING = 'PENDING', // 待发送
|
||||||
SENT = 'SENT', // 已发送到 Kafka,等待消费方确认
|
SENT = 'SENT', // 已发送到 Kafka,等待消费方确认
|
||||||
CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功
|
CONFIRMED = 'CONFIRMED', // 消费方已确认处理成功
|
||||||
FAILED = 'FAILED', // 发送失败,等待重试
|
FAILED = 'FAILED', // 发送失败,等待重试
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutboxEventData {
|
export interface OutboxEventData {
|
||||||
|
|
@ -44,7 +44,9 @@ export class OutboxRepository {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (events.length === 0) return;
|
if (events.length === 0) return;
|
||||||
|
|
||||||
this.logger.debug(`[OUTBOX] Saving ${events.length} events to outbox (in transaction)`);
|
this.logger.debug(
|
||||||
|
`[OUTBOX] Saving ${events.length} events to outbox (in transaction)`,
|
||||||
|
);
|
||||||
|
|
||||||
await tx.outboxEvent.createMany({
|
await tx.outboxEvent.createMany({
|
||||||
data: events.map((event) => ({
|
data: events.map((event) => ({
|
||||||
|
|
@ -139,7 +141,9 @@ export class OutboxRepository {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug(`[OUTBOX] Marked event ${id} as SENT (awaiting consumer confirmation)`);
|
this.logger.debug(
|
||||||
|
`[OUTBOX] Marked event ${id} as SENT (awaiting consumer confirmation)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -164,11 +168,15 @@ export class OutboxRepository {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.count > 0) {
|
if (result.count > 0) {
|
||||||
this.logger.log(`[OUTBOX] ✓ Event ${eventId} (${eventType || 'all types'}) confirmed by consumer`);
|
this.logger.log(
|
||||||
|
`[OUTBOX] ✓ Event ${eventId} (${eventType || 'all types'}) confirmed by consumer`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(`[OUTBOX] Event ${eventId} (${eventType || 'any'}) not found or not in SENT status`);
|
this.logger.warn(
|
||||||
|
`[OUTBOX] Event ${eventId} (${eventType || 'any'}) not found or not in SENT status`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +198,10 @@ export class OutboxRepository {
|
||||||
* 获取已发送但未确认且超时的事件(用于重试)
|
* 获取已发送但未确认且超时的事件(用于重试)
|
||||||
* B方案:超时未收到确认的事件需要重发
|
* B方案:超时未收到确认的事件需要重发
|
||||||
*/
|
*/
|
||||||
async findSentEventsTimedOut(timeoutMinutes: number = 5, limit: number = 50): Promise<OutboxEvent[]> {
|
async findSentEventsTimedOut(
|
||||||
|
timeoutMinutes: number = 5,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<OutboxEvent[]> {
|
||||||
const cutoffTime = new Date();
|
const cutoffTime = new Date();
|
||||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - timeoutMinutes);
|
cutoffTime.setMinutes(cutoffTime.getMinutes() - timeoutMinutes);
|
||||||
|
|
||||||
|
|
@ -232,7 +243,9 @@ export class OutboxRepository {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.warn(`[OUTBOX] Event ${id} reset to PENDING for retry (confirmation timeout)`);
|
this.logger.warn(
|
||||||
|
`[OUTBOX] Event ${id} reset to PENDING for retry (confirmation timeout)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -264,9 +277,13 @@ export class OutboxRepository {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFinalFailure) {
|
if (isFinalFailure) {
|
||||||
this.logger.error(`[OUTBOX] Event ${id} permanently failed after ${newRetryCount} retries: ${error}`);
|
this.logger.error(
|
||||||
|
`[OUTBOX] Event ${id} permanently failed after ${newRetryCount} retries: ${error}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[OUTBOX] Event ${id} failed, retry ${newRetryCount}/${event.maxRetries}, next retry at ${nextRetryAt}`);
|
this.logger.warn(
|
||||||
|
`[OUTBOX] Event ${id} failed, retry ${newRetryCount}/${event.maxRetries}, next retry at ${nextRetryAt}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,9 +320,13 @@ export class OutboxRepository {
|
||||||
failed: number;
|
failed: number;
|
||||||
}> {
|
}> {
|
||||||
const [pending, sent, confirmed, failed] = await Promise.all([
|
const [pending, sent, confirmed, failed] = await Promise.all([
|
||||||
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.PENDING } }),
|
this.prisma.outboxEvent.count({
|
||||||
|
where: { status: OutboxStatus.PENDING },
|
||||||
|
}),
|
||||||
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.SENT } }),
|
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.SENT } }),
|
||||||
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.CONFIRMED } }),
|
this.prisma.outboxEvent.count({
|
||||||
|
where: { status: OutboxStatus.CONFIRMED },
|
||||||
|
}),
|
||||||
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }),
|
this.prisma.outboxEvent.count({ where: { status: OutboxStatus.FAILED } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,29 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
UserAccountRepository, Pagination, ReferralLinkData, CreateReferralLinkParams,
|
UserAccountRepository,
|
||||||
|
Pagination,
|
||||||
|
ReferralLinkData,
|
||||||
|
CreateReferralLinkParams,
|
||||||
} from '@/domain/repositories/user-account.repository.interface';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import {
|
import {
|
||||||
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
|
UserId,
|
||||||
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
|
AccountSequence,
|
||||||
|
PhoneNumber,
|
||||||
|
ReferralCode,
|
||||||
|
ChainType,
|
||||||
|
AccountStatus,
|
||||||
|
KYCStatus,
|
||||||
|
DeviceInfo,
|
||||||
|
KYCInfo,
|
||||||
|
AddressStatus,
|
||||||
} from '@/domain/value-objects';
|
} from '@/domain/value-objects';
|
||||||
import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity';
|
import {
|
||||||
|
toMpcSignatureString,
|
||||||
|
fromMpcSignatureString,
|
||||||
|
} from '../entities/wallet-address.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAccountRepositoryImpl implements UserAccountRepository {
|
export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
|
|
@ -76,7 +90,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
userId: savedUserId,
|
userId: savedUserId,
|
||||||
deviceId: d.deviceId,
|
deviceId: d.deviceId,
|
||||||
deviceName: d.deviceName,
|
deviceName: d.deviceName,
|
||||||
deviceInfo: d.deviceInfo ? JSON.parse(JSON.stringify(d.deviceInfo)) : null, // 100% 保存完整 JSON
|
deviceInfo: d.deviceInfo
|
||||||
|
? JSON.parse(JSON.stringify(d.deviceInfo))
|
||||||
|
: null, // 100% 保存完整 JSON
|
||||||
platform: (info as any).platform || null,
|
platform: (info as any).platform || null,
|
||||||
deviceModel: (info as any).model || null,
|
deviceModel: (info as any).model || null,
|
||||||
osVersion: (info as any).osVersion || null,
|
osVersion: (info as any).osVersion || null,
|
||||||
|
|
@ -140,7 +156,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
return data ? this.toDomain(data) : null;
|
return data ? this.toDomain(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null> {
|
async findByAccountSequence(
|
||||||
|
sequence: AccountSequence,
|
||||||
|
): Promise<UserAccount | null> {
|
||||||
const data = await this.prisma.userAccount.findUnique({
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
where: { accountSequence: sequence.value },
|
where: { accountSequence: sequence.value },
|
||||||
include: { devices: true, walletAddresses: true },
|
include: { devices: true, walletAddresses: true },
|
||||||
|
|
@ -149,12 +167,16 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
|
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
|
||||||
const device = await this.prisma.userDevice.findFirst({ where: { deviceId } });
|
const device = await this.prisma.userDevice.findFirst({
|
||||||
|
where: { deviceId },
|
||||||
|
});
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
return this.findById(UserId.create(device.userId.toString()));
|
return this.findById(UserId.create(device.userId.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> {
|
async findByPhoneNumber(
|
||||||
|
phoneNumber: PhoneNumber,
|
||||||
|
): Promise<UserAccount | null> {
|
||||||
const data = await this.prisma.userAccount.findUnique({
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
where: { phoneNumber: phoneNumber.value },
|
where: { phoneNumber: phoneNumber.value },
|
||||||
include: { devices: true, walletAddresses: true },
|
include: { devices: true, walletAddresses: true },
|
||||||
|
|
@ -162,7 +184,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
return data ? this.toDomain(data) : null;
|
return data ? this.toDomain(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null> {
|
async findByReferralCode(
|
||||||
|
referralCode: ReferralCode,
|
||||||
|
): Promise<UserAccount | null> {
|
||||||
const data = await this.prisma.userAccount.findUnique({
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
where: { referralCode: referralCode.value },
|
where: { referralCode: referralCode.value },
|
||||||
include: { devices: true, walletAddresses: true },
|
include: { devices: true, walletAddresses: true },
|
||||||
|
|
@ -170,7 +194,10 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
return data ? this.toDomain(data) : null;
|
return data ? this.toDomain(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null> {
|
async findByWalletAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<UserAccount | null> {
|
||||||
const wallet = await this.prisma.walletAddress.findUnique({
|
const wallet = await this.prisma.walletAddress.findUnique({
|
||||||
where: { uk_chain_address: { chainType, address } },
|
where: { uk_chain_address: { chainType, address } },
|
||||||
});
|
});
|
||||||
|
|
@ -179,8 +206,12 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMaxAccountSequence(): Promise<AccountSequence | null> {
|
async getMaxAccountSequence(): Promise<AccountSequence | null> {
|
||||||
const result = await this.prisma.userAccount.aggregate({ _max: { accountSequence: true } });
|
const result = await this.prisma.userAccount.aggregate({
|
||||||
return result._max.accountSequence ? AccountSequence.create(result._max.accountSequence) : null;
|
_max: { accountSequence: true },
|
||||||
|
});
|
||||||
|
return result._max.accountSequence
|
||||||
|
? AccountSequence.create(result._max.accountSequence)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextAccountSequence(): Promise<AccountSequence> {
|
async getNextAccountSequence(): Promise<AccountSequence> {
|
||||||
|
|
@ -215,7 +246,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async findUsers(
|
async findUsers(
|
||||||
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string },
|
filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
|
kycStatus?: KYCStatus;
|
||||||
|
keyword?: string;
|
||||||
|
},
|
||||||
pagination?: Pagination,
|
pagination?: Pagination,
|
||||||
): Promise<UserAccount[]> {
|
): Promise<UserAccount[]> {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
@ -239,7 +274,10 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
return data.map((d) => this.toDomain(d));
|
return data.map((d) => this.toDomain(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number> {
|
async countUsers(filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
|
kycStatus?: KYCStatus;
|
||||||
|
}): Promise<number> {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (filters?.status) where.status = filters.status;
|
if (filters?.status) where.status = filters.status;
|
||||||
if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
|
if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
|
||||||
|
|
@ -254,7 +292,7 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
d.deviceName || '未命名设备',
|
d.deviceName || '未命名设备',
|
||||||
d.addedAt,
|
d.addedAt,
|
||||||
d.lastActiveAt,
|
d.lastActiveAt,
|
||||||
d.deviceInfo || undefined, // 100% 保持原样
|
d.deviceInfo || undefined, // 100% 保持原样
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,7 +304,7 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
address: w.address,
|
address: w.address,
|
||||||
publicKey: w.publicKey || '',
|
publicKey: w.publicKey || '',
|
||||||
addressDigest: w.addressDigest || '',
|
addressDigest: w.addressDigest || '',
|
||||||
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
|
mpcSignature: toMpcSignatureString(w), // 64 bytes hex (r + s)
|
||||||
status: w.status as AddressStatus,
|
status: w.status as AddressStatus,
|
||||||
boundAt: w.boundAt,
|
boundAt: w.boundAt,
|
||||||
}),
|
}),
|
||||||
|
|
@ -303,7 +341,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
|
|
||||||
// ============ 推荐相关 ============
|
// ============ 推荐相关 ============
|
||||||
|
|
||||||
async findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]> {
|
async findByInviterSequence(
|
||||||
|
inviterSequence: AccountSequence,
|
||||||
|
): Promise<UserAccount[]> {
|
||||||
const data = await this.prisma.userAccount.findMany({
|
const data = await this.prisma.userAccount.findMany({
|
||||||
where: { inviterSequence: inviterSequence.value },
|
where: { inviterSequence: inviterSequence.value },
|
||||||
include: { devices: true, walletAddresses: true },
|
include: { devices: true, walletAddresses: true },
|
||||||
|
|
@ -312,7 +352,9 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
return data.map((d) => this.toDomain(d));
|
return data.map((d) => this.toDomain(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData> {
|
async createReferralLink(
|
||||||
|
params: CreateReferralLinkParams,
|
||||||
|
): Promise<ReferralLinkData> {
|
||||||
const result = await this.prisma.referralLink.create({
|
const result = await this.prisma.referralLink.create({
|
||||||
data: {
|
data: {
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ async function bootstrap() {
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
// Kafka 微服务 - 用于 @MessagePattern 消费消息
|
// Kafka 微服务 - 用于 @MessagePattern 消费消息
|
||||||
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
|
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || [
|
||||||
|
'localhost:9092',
|
||||||
|
];
|
||||||
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'identity-service-group';
|
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'identity-service-group';
|
||||||
|
|
||||||
app.connectMicroservice<MicroserviceOptions>({
|
app.connectMicroservice<MicroserviceOptions>({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
import { CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
import { CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => {
|
(
|
||||||
|
data: keyof CurrentUserData | undefined,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): CurrentUserData | string | number => {
|
||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
const user = request.user as CurrentUserData;
|
const user = request.user as CurrentUserData;
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
|
import {
|
||||||
|
createParamDecorator,
|
||||||
|
ExecutionContext,
|
||||||
|
SetMetadata,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard';
|
import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard';
|
||||||
|
|
||||||
export interface CurrentUserPayload {
|
export interface CurrentUserPayload {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
import {
|
import {
|
||||||
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
|
ExceptionFilter,
|
||||||
Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger,
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception';
|
import {
|
||||||
|
DomainError,
|
||||||
|
ApplicationError,
|
||||||
|
} from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
|
|
@ -72,8 +83,14 @@ export interface ApiResponse<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
export class TransformInterceptor<T> implements NestInterceptor<
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
T,
|
||||||
|
ApiResponse<T>
|
||||||
|
> {
|
||||||
|
intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Observable<ApiResponse<T>> {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => ({
|
map((data) => ({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
import { Injectable, CanActivate, ExecutionContext, createParamDecorator, SetMetadata } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
createParamDecorator,
|
||||||
|
SetMetadata,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { UnauthorizedException } from '@/shared/exceptions/domain.exception';
|
import { UnauthorizedException } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentUserData {
|
export interface CurrentUserData {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,7 +26,10 @@ export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => {
|
(
|
||||||
|
data: keyof CurrentUserData | undefined,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): CurrentUserData | string | number => {
|
||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
const user = request.user as CurrentUserData;
|
const user = request.user as CurrentUserData;
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
|
|
@ -48,7 +57,8 @@ export class JwtAuthGuard implements CanActivate {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||||
if (payload.type !== 'access') throw new UnauthorizedException('无效的令牌类型');
|
if (payload.type !== 'access')
|
||||||
|
throw new UnauthorizedException('无效的令牌类型');
|
||||||
request.user = {
|
request.user = {
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
accountSequence: payload.accountSequence,
|
accountSequence: payload.accountSequence,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
|
@ -9,8 +14,14 @@ export interface ApiResponse<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
export class TransformInterceptor<T> implements NestInterceptor<
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
T,
|
||||||
|
ApiResponse<T>
|
||||||
|
> {
|
||||||
|
intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Observable<ApiResponse<T>> {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => ({
|
map((data) => ({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
iat: number;
|
iat: number;
|
||||||
|
|
|
||||||
|
|
@ -118,15 +118,18 @@ const DECORATIONS = [
|
||||||
*/
|
*/
|
||||||
export function generateRandomAvatarSvg(): string {
|
export function generateRandomAvatarSvg(): string {
|
||||||
// 随机选择配色
|
// 随机选择配色
|
||||||
const palette = COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)];
|
const palette =
|
||||||
|
COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)];
|
||||||
// 随机选择榴莲形状
|
// 随机选择榴莲形状
|
||||||
const shape = DURIAN_SHAPES[Math.floor(Math.random() * DURIAN_SHAPES.length)];
|
const shape = DURIAN_SHAPES[Math.floor(Math.random() * DURIAN_SHAPES.length)];
|
||||||
// 随机选择表情
|
// 随机选择表情
|
||||||
const face = FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)];
|
const face =
|
||||||
|
FACE_EXPRESSIONS[Math.floor(Math.random() * FACE_EXPRESSIONS.length)];
|
||||||
// 随机选择装饰 (50%概率有装饰)
|
// 随机选择装饰 (50%概率有装饰)
|
||||||
const decoration = Math.random() > 0.5
|
const decoration =
|
||||||
? DECORATIONS[Math.floor(Math.random() * (DECORATIONS.length - 1))]
|
Math.random() > 0.5
|
||||||
: DECORATIONS[DECORATIONS.length - 1];
|
? DECORATIONS[Math.floor(Math.random() * (DECORATIONS.length - 1))]
|
||||||
|
: DECORATIONS[DECORATIONS.length - 1];
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
<rect width="100" height="100" fill="${palette.bg}"/>
|
<rect width="100" height="100" fill="${palette.bg}"/>
|
||||||
|
|
@ -140,7 +143,10 @@ export function generateRandomAvatarSvg(): string {
|
||||||
* 生成用户身份
|
* 生成用户身份
|
||||||
* @param accountSequence 用户序列号 (格式: D + YYMMDD + 5位序号)
|
* @param accountSequence 用户序列号 (格式: D + YYMMDD + 5位序号)
|
||||||
*/
|
*/
|
||||||
export function generateIdentity(accountSequence: string): { username: string; avatarSvg: string } {
|
export function generateIdentity(accountSequence: string): {
|
||||||
|
username: string;
|
||||||
|
avatarSvg: string;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
username: generateUsername(accountSequence),
|
username: generateUsername(accountSequence),
|
||||||
avatarSvg: generateRandomAvatarSvg(),
|
avatarSvg: generateRandomAvatarSvg(),
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,15 @@ describe('Identity Service E2E Tests', () => {
|
||||||
expect(response.body.data.walletAddresses).toHaveProperty('kava');
|
expect(response.body.data.walletAddresses).toHaveProperty('kava');
|
||||||
expect(response.body.data.walletAddresses).toHaveProperty('dst');
|
expect(response.body.data.walletAddresses).toHaveProperty('dst');
|
||||||
expect(response.body.data.walletAddresses).toHaveProperty('bsc');
|
expect(response.body.data.walletAddresses).toHaveProperty('bsc');
|
||||||
expect(response.body.data.walletAddresses.kava).toMatch(/^kava1[a-z0-9]{38}$/);
|
expect(response.body.data.walletAddresses.kava).toMatch(
|
||||||
expect(response.body.data.walletAddresses.dst).toMatch(/^dst1[a-z0-9]{38}$/);
|
/^kava1[a-z0-9]{38}$/,
|
||||||
expect(response.body.data.walletAddresses.bsc).toMatch(/^0x[a-fA-F0-9]{40}$/);
|
);
|
||||||
|
expect(response.body.data.walletAddresses.dst).toMatch(
|
||||||
|
/^dst1[a-z0-9]{38}$/,
|
||||||
|
);
|
||||||
|
expect(response.body.data.walletAddresses.bsc).toMatch(
|
||||||
|
/^0x[a-fA-F0-9]{40}$/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该验证请求参数', async () => {
|
it('应该验证请求参数', async () => {
|
||||||
|
|
@ -182,7 +188,9 @@ describe('Identity Service E2E Tests', () => {
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(profileResponse.body.data.nickname).toBe('测试用户');
|
expect(profileResponse.body.data.nickname).toBe('测试用户');
|
||||||
expect(profileResponse.body.data.avatarUrl).toBe('https://example.com/avatar.jpg');
|
expect(profileResponse.body.data.avatarUrl).toBe(
|
||||||
|
'https://example.com/avatar.jpg',
|
||||||
|
);
|
||||||
expect(profileResponse.body.data.address).toBe('测试地址');
|
expect(profileResponse.body.data.address).toBe('测试地址');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -283,7 +291,7 @@ describe('Identity Service E2E Tests', () => {
|
||||||
if (response.status !== 201) {
|
if (response.status !== 201) {
|
||||||
console.log(`Device limit test failed at iteration ${i}:`, {
|
console.log(`Device limit test failed at iteration ${i}:`, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
body: response.body
|
body: response.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,8 +336,11 @@ describe('Identity Service E2E Tests', () => {
|
||||||
console.log('Auto-login failed:', {
|
console.log('Auto-login failed:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
body: response.body,
|
body: response.body,
|
||||||
sentData: { refreshToken: refreshToken?.substring(0, 20) + '...', deviceId: validDeviceId },
|
sentData: {
|
||||||
availableDevices: devicesResponse.body.data
|
refreshToken: refreshToken?.substring(0, 20) + '...',
|
||||||
|
deviceId: validDeviceId,
|
||||||
|
},
|
||||||
|
availableDevices: devicesResponse.body.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,7 +371,7 @@ describe('Identity Service E2E Tests', () => {
|
||||||
console.log('Invalid token test failed:', {
|
console.log('Invalid token test failed:', {
|
||||||
expectedStatus: 401,
|
expectedStatus: 401,
|
||||||
actualStatus: response.status,
|
actualStatus: response.status,
|
||||||
body: response.body
|
body: response.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,7 +474,7 @@ describe('Identity Service E2E Tests', () => {
|
||||||
if (response.status !== 201) {
|
if (response.status !== 201) {
|
||||||
console.log('Mnemonic recovery failed:', {
|
console.log('Mnemonic recovery failed:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
body: response.body
|
body: response.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,7 +490,8 @@ describe('Identity Service E2E Tests', () => {
|
||||||
.post('/api/v1/user/recover-by-mnemonic')
|
.post('/api/v1/user/recover-by-mnemonic')
|
||||||
.send({
|
.send({
|
||||||
accountSequence,
|
accountSequence,
|
||||||
mnemonic: 'wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong',
|
mnemonic:
|
||||||
|
'wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong wrong',
|
||||||
newDeviceId: `wrong-device-${Date.now()}`,
|
newDeviceId: `wrong-device-${Date.now()}`,
|
||||||
deviceName: '错误设备',
|
deviceName: '错误设备',
|
||||||
})
|
})
|
||||||
|
|
@ -501,7 +513,7 @@ describe('Identity Service E2E Tests', () => {
|
||||||
console.log('Mismatch account sequence test failed:', {
|
console.log('Mismatch account sequence test failed:', {
|
||||||
expectedStatus: 404,
|
expectedStatus: 404,
|
||||||
actualStatus: response.status,
|
actualStatus: response.status,
|
||||||
body: response.body
|
body: response.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,7 +557,7 @@ describe('Identity Service E2E Tests', () => {
|
||||||
if (response.status === 400) {
|
if (response.status === 400) {
|
||||||
console.log(`Phone format test failed for ${phone}:`, {
|
console.log(`Phone format test failed for ${phone}:`, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
body: response.body
|
body: response.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,12 @@ describe('AutoCreateAccount (e2e)', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 两个账号应该有不同的序列号和钱包地址
|
// 两个账号应该有不同的序列号和钱包地址
|
||||||
expect(response1.body.accountSequence).not.toBe(response2.body.accountSequence);
|
expect(response1.body.accountSequence).not.toBe(
|
||||||
expect(response1.body.walletAddresses.bsc).not.toBe(response2.body.walletAddresses.bsc);
|
response2.body.accountSequence,
|
||||||
|
);
|
||||||
|
expect(response1.body.walletAddresses.bsc).not.toBe(
|
||||||
|
response2.body.walletAddresses.bsc,
|
||||||
|
);
|
||||||
expect(response1.body.publicKey).not.toBe(response2.body.publicKey);
|
expect(response1.body.publicKey).not.toBe(response2.body.publicKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,55 @@ class AccountService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 手机号+密码登录 (POST /user/login-with-password)
|
||||||
|
///
|
||||||
|
/// 用于账号恢复功能
|
||||||
|
Future<PhoneAuthResponse> loginWithPassword({
|
||||||
|
required String phoneNumber,
|
||||||
|
required String password,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
debugPrint('$_tag loginWithPassword() - 开始登录');
|
||||||
|
debugPrint('$_tag loginWithPassword() - 手机号: $phoneNumber');
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint('$_tag loginWithPassword() - 调用 POST /user/login-with-password');
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/user/login-with-password',
|
||||||
|
data: {
|
||||||
|
'phoneNumber': phoneNumber,
|
||||||
|
'password': password,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('$_tag loginWithPassword() - API 响应状态码: ${response.statusCode}');
|
||||||
|
|
||||||
|
if (response.data == null) {
|
||||||
|
debugPrint('$_tag loginWithPassword() - 错误: API 返回空响应');
|
||||||
|
throw const ApiException('登录失败: 空响应');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('$_tag loginWithPassword() - 解析响应数据');
|
||||||
|
final responseData = response.data as Map<String, dynamic>;
|
||||||
|
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||||
|
final result = PhoneAuthResponse.fromJson(data);
|
||||||
|
debugPrint('$_tag loginWithPassword() - 登录成功: ${result.accountSequence}');
|
||||||
|
|
||||||
|
// 保存登录信息
|
||||||
|
await _savePhoneAuthData(result, deviceId, phoneNumber);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
debugPrint('$_tag loginWithPassword() - API 异常: $e');
|
||||||
|
rethrow;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint('$_tag loginWithPassword() - 未知异常: $e');
|
||||||
|
debugPrint('$_tag loginWithPassword() - 堆栈: $stackTrace');
|
||||||
|
throw ApiException('登录失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 手动重试钱包生成 (POST /user/wallet/retry)
|
/// 手动重试钱包生成 (POST /user/wallet/retry)
|
||||||
///
|
///
|
||||||
/// 当钱包生成失败或超时时,用户可手动触发重试
|
/// 当钱包生成失败或超时时,用户可手动触发重试
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class _GuidePageState extends ConsumerState<GuidePage> {
|
||||||
GuidePageData(
|
GuidePageData(
|
||||||
imagePath: 'assets/images/guide_5.jpg',
|
imagePath: 'assets/images/guide_5.jpg',
|
||||||
title: '欢迎加入',
|
title: '欢迎加入',
|
||||||
subtitle: '创建账号前的最后一步 · 请选择是否有推荐人',
|
subtitle: '注册账号前的最后一步 · 请选择是否有推荐人',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -516,7 +516,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
||||||
SizedBox(height: 12.h),
|
SizedBox(height: 12.h),
|
||||||
// 副标题
|
// 副标题
|
||||||
Text(
|
Text(
|
||||||
'创建账号前的最后一步 · 请输入推荐码',
|
'注册账号前的最后一步 · 请输入推荐码',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
height: 1.43,
|
height: 1.43,
|
||||||
|
|
@ -549,7 +549,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'下一步 (创建账号)',
|
'下一步 (注册账号)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.sp,
|
fontSize: 16.sp,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
import '../../../../core/services/account_service.dart';
|
|
||||||
import '../../../../core/storage/secure_storage.dart';
|
|
||||||
import '../../../../core/storage/storage_keys.dart';
|
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
|
@ -91,26 +88,29 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
||||||
|
|
||||||
debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone');
|
debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone');
|
||||||
|
|
||||||
// 调用登录 API(需要在 AccountService 中添加)
|
// 获取 AccountService
|
||||||
// TODO: 实现手机号+密码登录 API
|
final accountService = ref.read(accountServiceProvider);
|
||||||
// final response = await accountService.loginWithPassword(phone, password);
|
|
||||||
|
|
||||||
// 暂时模拟登录失败
|
// 获取设备ID
|
||||||
throw Exception('手机号+密码登录 API 尚未实现');
|
final deviceId = await accountService.getDeviceId();
|
||||||
|
debugPrint('[PhoneLoginPage] 获取设备ID成功');
|
||||||
|
|
||||||
// 登录成功后的处理:
|
// 调用登录 API
|
||||||
// 1. 保存 access token 和 refresh token
|
final response = await accountService.loginWithPassword(
|
||||||
// 2. 保存用户信息(userId, accountSequence, referralCode)
|
phoneNumber: phone,
|
||||||
// 3. 检查钱包状态
|
password: password,
|
||||||
// 4. 跳转到主页
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
// if (mounted) {
|
debugPrint('[PhoneLoginPage] 登录成功 - accountSequence: ${response.accountSequence}');
|
||||||
// // 更新认证状态
|
|
||||||
// await ref.read(authProvider.notifier).checkAuthStatus();
|
if (mounted) {
|
||||||
//
|
// 更新认证状态
|
||||||
// // 跳转到主页(龙虎榜)
|
await ref.read(authProvider.notifier).checkAuthStatus();
|
||||||
// context.go(RoutePaths.ranking);
|
|
||||||
// }
|
// 跳转到主页(龙虎榜)
|
||||||
|
context.go(RoutePaths.ranking);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[PhoneLoginPage] 登录失败: $e');
|
debugPrint('[PhoneLoginPage] 登录失败: $e');
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue