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

## 后端更改

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

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

## 前端更改

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

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

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

## 已验证功能

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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