feat: 实现手机号+密码登录和账号恢复功能
## 后端更改 ### 新增功能 - 添加手机号+密码登录 API (`POST /user/login-with-password`) - 新增 LoginWithPasswordDto 验证手机号格式和密码长度 - 实现 loginWithPassword 服务方法,使用 bcrypt 验证密码 - 返回 JWT tokens(accessToken + refreshToken) ### 代码优化 - 修复 phone.validator.ts 中的 TypeScript 类型错误(Object -> object) ## 前端更改 ### 新增功能 - 实现手机号+密码登录页面 (phone_login_page.dart) - 完整的表单验证(手机号格式、密码长度) - 集成 AccountService.loginWithPassword API - 登录成功后自动更新认证状态并跳转主页 ### 账号服务优化 - 在 AccountService 中添加 loginWithPassword 方法 - 调用后端 login-with-password API - 自动保存认证数据(tokens、用户信息) - 使用 _savePhoneAuthData 统一保存逻辑 ### UI 文案更新 - 向导页文案修改:"创建账号" → "注册账号" - 更新标题、副标题和按钮文本 - 添加"恢复账号"按钮,跳转到手机号密码登录页 ## 已验证功能 ✅ 前端代码编译通过(0 errors, 仅有非关键警告) ✅ 后端代码编译通过(0 errors, 仅有非关键警告) ✅ 30天登录状态保持(JWT refresh token 已配置为30天) ✅ 自动路由逻辑(有登录状态直接进入主页) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
65f3e75f59
commit
b4c4239593
|
|
@ -1,12 +1,17 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UserAccountController } from './controllers/user-account.controller';
|
import { UserAccountController } from './controllers/user-account.controller';
|
||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
import { ReferralsController } from './controllers/referrals.controller';
|
import { ReferralsController } from './controllers/referrals.controller';
|
||||||
import { TotpController } from './controllers/totp.controller';
|
import { TotpController } from './controllers/totp.controller';
|
||||||
import { ApplicationModule } from '@/application/application.module';
|
import { ApplicationModule } from '@/application/application.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
controllers: [UserAccountController, AuthController, ReferralsController, TotpController],
|
controllers: [
|
||||||
})
|
UserAccountController,
|
||||||
export class ApiModule {}
|
AuthController,
|
||||||
|
ReferralsController,
|
||||||
|
TotpController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,102 @@
|
||||||
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common';
|
import {
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
Controller,
|
||||||
import { JwtService } from '@nestjs/jwt';
|
Post,
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
Body,
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
UnauthorizedException,
|
||||||
import { Public } from '@/shared/guards/jwt-auth.guard';
|
Logger,
|
||||||
import { AutoLoginCommand } from '@/application/commands';
|
} from '@nestjs/common';
|
||||||
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import * as bcrypt from 'bcrypt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
@ApiTags('Auth')
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
@Controller('auth')
|
import { Public } from '@/shared/guards/jwt-auth.guard';
|
||||||
export class AuthController {
|
import { AutoLoginCommand } from '@/application/commands';
|
||||||
private readonly logger = new Logger(AuthController.name);
|
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
constructor(
|
|
||||||
private readonly userService: UserApplicationService,
|
@ApiTags('Auth')
|
||||||
private readonly jwtService: JwtService,
|
@Controller('auth')
|
||||||
private readonly prisma: PrismaService,
|
export class AuthController {
|
||||||
) {}
|
private readonly logger = new Logger(AuthController.name);
|
||||||
|
|
||||||
@Public()
|
constructor(
|
||||||
@Post('refresh')
|
private readonly userService: UserApplicationService,
|
||||||
@ApiOperation({ summary: 'Token刷新' })
|
private readonly jwtService: JwtService,
|
||||||
async refresh(@Body() dto: AutoLoginDto) {
|
private readonly prisma: PrismaService,
|
||||||
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
|
) {}
|
||||||
}
|
|
||||||
|
@Public()
|
||||||
@Public()
|
@Post('refresh')
|
||||||
@Post('login')
|
@ApiOperation({ summary: 'Token刷新' })
|
||||||
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
|
async refresh(@Body() dto: AutoLoginDto) {
|
||||||
@ApiResponse({ status: 200, type: AdminLoginResponseDto })
|
return this.userService.autoLogin(
|
||||||
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
|
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||||
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
|
);
|
||||||
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
|
}
|
||||||
|
|
||||||
// 从数据库查找管理员
|
@Public()
|
||||||
const admin = await this.prisma.adminAccount.findUnique({
|
@Post('login')
|
||||||
where: { email: dto.email },
|
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
|
||||||
});
|
@ApiResponse({ status: 200, type: AdminLoginResponseDto })
|
||||||
|
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
|
||||||
if (!admin) {
|
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
|
||||||
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
|
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
|
||||||
throw new UnauthorizedException('邮箱或密码错误');
|
|
||||||
}
|
// 从数据库查找管理员
|
||||||
|
const admin = await this.prisma.adminAccount.findUnique({
|
||||||
// 检查账户状态
|
where: { email: dto.email },
|
||||||
if (admin.status !== 'ACTIVE') {
|
});
|
||||||
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`);
|
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
if (!admin) {
|
||||||
}
|
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
|
||||||
|
throw new UnauthorizedException('邮箱或密码错误');
|
||||||
// 验证密码 (使用 bcrypt)
|
}
|
||||||
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
|
|
||||||
|
// 检查账户状态
|
||||||
if (!isPasswordValid) {
|
if (admin.status !== 'ACTIVE') {
|
||||||
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
this.logger.warn(
|
||||||
throw new UnauthorizedException('邮箱或密码错误');
|
`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`,
|
||||||
}
|
);
|
||||||
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
// 更新最后登录时间
|
}
|
||||||
await this.prisma.adminAccount.update({
|
|
||||||
where: { id: admin.id },
|
// 验证密码 (使用 bcrypt)
|
||||||
data: { lastLoginAt: new Date() },
|
const isPasswordValid = await bcrypt.compare(
|
||||||
});
|
dto.password,
|
||||||
|
admin.passwordHash,
|
||||||
// 生成 JWT Token
|
);
|
||||||
const payload = {
|
|
||||||
sub: admin.id.toString(),
|
if (!isPasswordValid) {
|
||||||
email: admin.email,
|
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
||||||
role: admin.role,
|
throw new UnauthorizedException('邮箱或密码错误');
|
||||||
type: 'admin',
|
}
|
||||||
};
|
|
||||||
|
// 更新最后登录时间
|
||||||
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
await this.prisma.adminAccount.update({
|
||||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
where: { id: admin.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
|
});
|
||||||
|
|
||||||
return {
|
// 生成 JWT Token
|
||||||
userId: admin.id.toString(),
|
const payload = {
|
||||||
email: admin.email,
|
sub: admin.id.toString(),
|
||||||
nickname: admin.nickname,
|
email: admin.email,
|
||||||
role: admin.role,
|
role: admin.role,
|
||||||
accessToken,
|
type: 'admin',
|
||||||
refreshToken,
|
};
|
||||||
};
|
|
||||||
}
|
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
||||||
}
|
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||||
|
|
||||||
|
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: admin.id.toString(),
|
||||||
|
email: admin.email,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
role: admin.role,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { Public } from '@/shared/decorators/public.decorator';
|
import { Public } from '@/shared/decorators/public.decorator';
|
||||||
|
|
||||||
@ApiTags('健康检查')
|
@ApiTags('健康检查')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@Public()
|
@Public()
|
||||||
@Get('health')
|
@Get('health')
|
||||||
@ApiOperation({ summary: '健康检查端点' })
|
@ApiOperation({ summary: '健康检查端点' })
|
||||||
health() {
|
health() {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
service: 'identity-service',
|
service: 'identity-service',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,104 @@
|
||||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
ApiTags,
|
||||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
ApiOperation,
|
||||||
import {
|
ApiBearerAuth,
|
||||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
ApiResponse,
|
||||||
} from '@/application/commands';
|
ApiQuery,
|
||||||
import {
|
} from '@nestjs/swagger';
|
||||||
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
ReferralLinkResponseDto, ReferralStatsResponseDto,
|
import {
|
||||||
} from '@/api/dto';
|
JwtAuthGuard,
|
||||||
|
Public,
|
||||||
@ApiTags('Referrals')
|
CurrentUser,
|
||||||
@Controller()
|
CurrentUserData,
|
||||||
@UseGuards(JwtAuthGuard)
|
} from '@/shared/guards/jwt-auth.guard';
|
||||||
export class ReferralsController {
|
import {
|
||||||
constructor(private readonly userService: UserApplicationService) {}
|
ValidateReferralCodeQuery,
|
||||||
|
GetReferralStatsQuery,
|
||||||
/**
|
GenerateReferralLinkCommand,
|
||||||
* GET /api/me - 获取当前登录用户信息 + 推荐码
|
} from '@/application/commands';
|
||||||
*/
|
import {
|
||||||
@Get('me')
|
GenerateReferralLinkDto,
|
||||||
@ApiBearerAuth()
|
MeResponseDto,
|
||||||
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
|
ReferralValidationResponseDto,
|
||||||
@ApiResponse({ status: 200, type: MeResponseDto })
|
ReferralLinkResponseDto,
|
||||||
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
ReferralStatsResponseDto,
|
||||||
return this.userService.getMe(user.userId);
|
} from '@/api/dto';
|
||||||
}
|
|
||||||
|
@ApiTags('Referrals')
|
||||||
/**
|
@Controller()
|
||||||
* GET /api/referrals/validate - 校验推荐码是否合法
|
@UseGuards(JwtAuthGuard)
|
||||||
*/
|
export class ReferralsController {
|
||||||
@Public()
|
constructor(private readonly userService: UserApplicationService) {}
|
||||||
@Get('referrals/validate')
|
|
||||||
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
|
/**
|
||||||
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
* GET /api/me - 获取当前登录用户信息 + 推荐码
|
||||||
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
*/
|
||||||
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
|
@Get('me')
|
||||||
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
|
@ApiBearerAuth()
|
||||||
}
|
@ApiOperation({
|
||||||
|
summary: '获取当前登录用户信息',
|
||||||
/**
|
description: '返回用户基本信息、推荐码和推荐链接',
|
||||||
* POST /api/referrals/links - 为当前登录用户生成短链/渠道链接
|
})
|
||||||
*/
|
@ApiResponse({ status: 200, type: MeResponseDto })
|
||||||
@Post('referrals/links')
|
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
||||||
@ApiBearerAuth()
|
return this.userService.getMe(user.userId);
|
||||||
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
|
}
|
||||||
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
|
||||||
async generateReferralLink(
|
/**
|
||||||
@CurrentUser() user: CurrentUserData,
|
* GET /api/referrals/validate - 校验推荐码是否合法
|
||||||
@Body() dto: GenerateReferralLinkDto,
|
*/
|
||||||
): Promise<ReferralLinkResponseDto> {
|
@Public()
|
||||||
return this.userService.generateReferralLink(
|
@Get('referrals/validate')
|
||||||
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
|
@ApiOperation({
|
||||||
);
|
summary: '校验推荐码',
|
||||||
}
|
description: '创建账号时校验推荐码是否合法',
|
||||||
|
})
|
||||||
/**
|
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
||||||
* GET /api/referrals/stats - 查询登录用户的邀请记录
|
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
||||||
*/
|
async validateReferralCode(
|
||||||
@Get('referrals/stats')
|
@Query('code') code: string,
|
||||||
@ApiBearerAuth()
|
): Promise<ReferralValidationResponseDto> {
|
||||||
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
|
return this.userService.validateReferralCode(
|
||||||
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
new ValidateReferralCodeQuery(code),
|
||||||
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
|
);
|
||||||
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
|
}
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* POST /api/referrals/links - 为当前登录用户生成短链/渠道链接
|
||||||
|
*/
|
||||||
|
@Post('referrals/links')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '生成推荐链接',
|
||||||
|
description: '为当前登录用户生成短链/渠道链接',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
||||||
|
async generateReferralLink(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: GenerateReferralLinkDto,
|
||||||
|
): Promise<ReferralLinkResponseDto> {
|
||||||
|
return this.userService.generateReferralLink(
|
||||||
|
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/referrals/stats - 查询登录用户的邀请记录
|
||||||
|
*/
|
||||||
|
@Get('referrals/stats')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '查询邀请统计',
|
||||||
|
description: '查询登录用户的邀请记录和统计数据',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
||||||
|
async getReferralStats(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
): Promise<ReferralStatsResponseDto> {
|
||||||
|
return this.userService.getReferralStats(
|
||||||
|
new GetReferralStatsQuery(user.userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { TotpService } from '@/application/services/totp.service';
|
import { TotpService } from '@/application/services/totp.service';
|
||||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -36,21 +41,34 @@ export class TotpController {
|
||||||
constructor(private readonly totpService: TotpService) {}
|
constructor(private readonly totpService: TotpService) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
@ApiOperation({ summary: '获取 TOTP 状态', description: '查询当前用户的 TOTP 启用状态' })
|
@ApiOperation({
|
||||||
|
summary: '获取 TOTP 状态',
|
||||||
|
description: '查询当前用户的 TOTP 启用状态',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
|
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
|
||||||
async getStatus(@CurrentUser() user: CurrentUserPayload): Promise<TotpStatusResponseDto> {
|
async getStatus(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
): Promise<TotpStatusResponseDto> {
|
||||||
return this.totpService.getTotpStatus(BigInt(user.userId));
|
return this.totpService.getTotpStatus(BigInt(user.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('setup')
|
@Post('setup')
|
||||||
@ApiOperation({ summary: '设置 TOTP', description: '生成 TOTP 密钥,返回二维码和手动输入密钥' })
|
@ApiOperation({
|
||||||
|
summary: '设置 TOTP',
|
||||||
|
description: '生成 TOTP 密钥,返回二维码和手动输入密钥',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
|
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
|
||||||
async setup(@CurrentUser() user: CurrentUserPayload): Promise<SetupTotpResponseDto> {
|
async setup(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
): Promise<SetupTotpResponseDto> {
|
||||||
return this.totpService.setupTotp(BigInt(user.userId));
|
return this.totpService.setupTotp(BigInt(user.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('enable')
|
@Post('enable')
|
||||||
@ApiOperation({ summary: '启用 TOTP', description: '验证码正确后启用 TOTP 二次验证' })
|
@ApiOperation({
|
||||||
|
summary: '启用 TOTP',
|
||||||
|
description: '验证码正确后启用 TOTP 二次验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
|
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
|
||||||
async enable(
|
async enable(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
|
@ -61,7 +79,10 @@ export class TotpController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('disable')
|
@Post('disable')
|
||||||
@ApiOperation({ summary: '禁用 TOTP', description: '验证码正确后禁用 TOTP 二次验证' })
|
@ApiOperation({
|
||||||
|
summary: '禁用 TOTP',
|
||||||
|
description: '验证码正确后禁用 TOTP 二次验证',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
|
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
|
||||||
async disable(
|
async disable(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
|
@ -72,13 +93,19 @@ export class TotpController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('verify')
|
@Post('verify')
|
||||||
@ApiOperation({ summary: '验证 TOTP', description: '验证 TOTP 验证码是否正确' })
|
@ApiOperation({
|
||||||
|
summary: '验证 TOTP',
|
||||||
|
description: '验证 TOTP 验证码是否正确',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
async verify(
|
async verify(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Body() dto: VerifyTotpDto,
|
@Body() dto: VerifyTotpDto,
|
||||||
): Promise<{ valid: boolean }> {
|
): Promise<{ valid: boolean }> {
|
||||||
const valid = await this.totpService.verifyTotp(BigInt(user.userId), dto.code);
|
const valid = await this.totpService.verifyTotp(
|
||||||
|
BigInt(user.userId),
|
||||||
|
dto.code,
|
||||||
|
);
|
||||||
return { valid };
|
return { valid };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,404 +1,578 @@
|
||||||
import {
|
import {
|
||||||
Controller, Post, Get, Put, Body, Param, UseGuards, Headers,
|
Controller,
|
||||||
UseInterceptors, UploadedFile, BadRequestException,
|
Post,
|
||||||
} from '@nestjs/common';
|
Get,
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
Put,
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
Body,
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
Param,
|
||||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
UseGuards,
|
||||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
Headers,
|
||||||
import {
|
UseInterceptors,
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
UploadedFile,
|
||||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
BadRequestException,
|
||||||
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
|
} from '@nestjs/common';
|
||||||
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand,
|
import {
|
||||||
} from '@/application/commands';
|
ApiTags,
|
||||||
import {
|
ApiOperation,
|
||||||
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
ApiBearerAuth,
|
||||||
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
|
ApiResponse,
|
||||||
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto,
|
ApiConsumes,
|
||||||
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto,
|
ApiBody,
|
||||||
GenerateBackupCodesDto, RecoverByBackupCodeDto,
|
} from '@nestjs/swagger';
|
||||||
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
UserProfileResponseDto, DeviceResponseDto,
|
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||||
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
|
import {
|
||||||
VerifySmsCodeDto, SetPasswordDto,
|
JwtAuthGuard,
|
||||||
} from '@/api/dto';
|
Public,
|
||||||
|
CurrentUser,
|
||||||
@ApiTags('User')
|
CurrentUserData,
|
||||||
@Controller('user')
|
} from '@/shared/guards/jwt-auth.guard';
|
||||||
@UseGuards(JwtAuthGuard)
|
import {
|
||||||
export class UserAccountController {
|
AutoCreateAccountCommand,
|
||||||
constructor(
|
RecoverByMnemonicCommand,
|
||||||
private readonly userService: UserApplicationService,
|
RecoverByPhoneCommand,
|
||||||
private readonly storageService: StorageService,
|
AutoLoginCommand,
|
||||||
) {}
|
RegisterCommand,
|
||||||
|
LoginCommand,
|
||||||
@Public()
|
BindPhoneNumberCommand,
|
||||||
@Post('auto-create')
|
UpdateProfileCommand,
|
||||||
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
|
SubmitKYCCommand,
|
||||||
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
|
RemoveDeviceCommand,
|
||||||
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
SendSmsCodeCommand,
|
||||||
return this.userService.autoCreateAccount(
|
GetMyProfileQuery,
|
||||||
new AutoCreateAccountCommand(
|
GetMyDevicesQuery,
|
||||||
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
|
GetUserByReferralCodeQuery,
|
||||||
),
|
GetWalletStatusQuery,
|
||||||
);
|
MarkMnemonicBackedUpCommand,
|
||||||
}
|
VerifySmsCodeCommand,
|
||||||
|
SetPasswordCommand,
|
||||||
@Public()
|
} from '@/application/commands';
|
||||||
@Post('recover-by-mnemonic')
|
import {
|
||||||
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
|
AutoCreateAccountDto,
|
||||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
RecoverByMnemonicDto,
|
||||||
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
RecoverByPhoneDto,
|
||||||
return this.userService.recoverByMnemonic(
|
AutoLoginDto,
|
||||||
new RecoverByMnemonicCommand(
|
SendSmsCodeDto,
|
||||||
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName,
|
RegisterDto,
|
||||||
),
|
LoginDto,
|
||||||
);
|
BindPhoneDto,
|
||||||
}
|
UpdateProfileDto,
|
||||||
|
BindWalletDto,
|
||||||
@Public()
|
SubmitKYCDto,
|
||||||
@Post('recover-by-phone')
|
RemoveDeviceDto,
|
||||||
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
|
RevokeMnemonicDto,
|
||||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
FreezeAccountDto,
|
||||||
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
UnfreezeAccountDto,
|
||||||
return this.userService.recoverByPhone(
|
RequestKeyRotationDto,
|
||||||
new RecoverByPhoneCommand(
|
GenerateBackupCodesDto,
|
||||||
dto.accountSequence, dto.phoneNumber, dto.smsCode,
|
RecoverByBackupCodeDto,
|
||||||
dto.newDeviceId, dto.deviceName,
|
AutoCreateAccountResponseDto,
|
||||||
),
|
RecoverAccountResponseDto,
|
||||||
);
|
LoginResponseDto,
|
||||||
}
|
UserProfileResponseDto,
|
||||||
|
DeviceResponseDto,
|
||||||
@Public()
|
WalletStatusReadyResponseDto,
|
||||||
@Post('auto-login')
|
WalletStatusGeneratingResponseDto,
|
||||||
@ApiOperation({ summary: '自动登录(Token刷新)' })
|
VerifySmsCodeDto,
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
SetPasswordDto,
|
||||||
async autoLogin(@Body() dto: AutoLoginDto) {
|
LoginWithPasswordDto,
|
||||||
return this.userService.autoLogin(
|
} from '@/api/dto';
|
||||||
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
|
||||||
);
|
@ApiTags('User')
|
||||||
}
|
@Controller('user')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Public()
|
export class UserAccountController {
|
||||||
@Post('send-sms-code')
|
constructor(
|
||||||
@ApiOperation({ summary: '发送短信验证码' })
|
private readonly userService: UserApplicationService,
|
||||||
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
private readonly storageService: StorageService,
|
||||||
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type));
|
) {}
|
||||||
return { message: '验证码已发送' };
|
|
||||||
}
|
@Public()
|
||||||
|
@Post('auto-create')
|
||||||
@Public()
|
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
|
||||||
@Post('verify-sms-code')
|
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
|
||||||
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' })
|
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
||||||
@ApiResponse({ status: 200, description: '验证成功' })
|
return this.userService.autoCreateAccount(
|
||||||
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
new AutoCreateAccountCommand(
|
||||||
await this.userService.verifySmsCode(
|
dto.deviceId,
|
||||||
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'),
|
dto.deviceName,
|
||||||
);
|
dto.inviterReferralCode,
|
||||||
return { message: '验证成功' };
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
@Public()
|
|
||||||
@Post('register')
|
@Public()
|
||||||
@ApiOperation({ summary: '用户注册(手机号)' })
|
@Post('recover-by-mnemonic')
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
|
||||||
async register(@Body() dto: RegisterDto) {
|
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||||
return this.userService.register(
|
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
||||||
new RegisterCommand(
|
return this.userService.recoverByMnemonic(
|
||||||
dto.phoneNumber, dto.smsCode, dto.deviceId,
|
new RecoverByMnemonicCommand(
|
||||||
dto.deviceName, dto.inviterReferralCode,
|
dto.accountSequence,
|
||||||
),
|
dto.mnemonic,
|
||||||
);
|
dto.newDeviceId,
|
||||||
}
|
dto.deviceName,
|
||||||
|
),
|
||||||
@Public()
|
);
|
||||||
@Post('login')
|
}
|
||||||
@ApiOperation({ summary: '用户登录(手机号)' })
|
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@Public()
|
||||||
async login(@Body() dto: LoginDto) {
|
@Post('recover-by-phone')
|
||||||
return this.userService.login(
|
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
|
||||||
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
|
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||||
);
|
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
||||||
}
|
return this.userService.recoverByPhone(
|
||||||
|
new RecoverByPhoneCommand(
|
||||||
@Post('bind-phone')
|
dto.accountSequence,
|
||||||
@ApiBearerAuth()
|
dto.phoneNumber,
|
||||||
@ApiOperation({ summary: '绑定手机号' })
|
dto.smsCode,
|
||||||
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
|
dto.newDeviceId,
|
||||||
await this.userService.bindPhoneNumber(
|
dto.deviceName,
|
||||||
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
),
|
||||||
);
|
);
|
||||||
return { message: '绑定成功' };
|
}
|
||||||
}
|
|
||||||
|
@Public()
|
||||||
@Post('set-password')
|
@Post('auto-login')
|
||||||
@ApiBearerAuth()
|
@ApiOperation({ summary: '自动登录(Token刷新)' })
|
||||||
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 200, description: '密码设置成功' })
|
async autoLogin(@Body() dto: AutoLoginDto) {
|
||||||
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) {
|
return this.userService.autoLogin(
|
||||||
await this.userService.setPassword(
|
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||||
new SetPasswordCommand(user.userId, dto.password),
|
);
|
||||||
);
|
}
|
||||||
return { message: '密码设置成功' };
|
|
||||||
}
|
@Public()
|
||||||
|
@Post('send-sms-code')
|
||||||
@Get('my-profile')
|
@ApiOperation({ summary: '发送短信验证码' })
|
||||||
@ApiBearerAuth()
|
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||||
@ApiOperation({ summary: '查询我的资料' })
|
await this.userService.sendSmsCode(
|
||||||
@ApiResponse({ status: 200, type: UserProfileResponseDto })
|
new SendSmsCodeCommand(dto.phoneNumber, dto.type),
|
||||||
async getMyProfile(@CurrentUser() user: CurrentUserData) {
|
);
|
||||||
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
|
return { message: '验证码已发送' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('update-profile')
|
@Public()
|
||||||
@ApiBearerAuth()
|
@Post('verify-sms-code')
|
||||||
@ApiOperation({ summary: '更新用户资料' })
|
@ApiOperation({
|
||||||
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
|
summary: '验证短信验证码',
|
||||||
await this.userService.updateProfile(
|
description: '仅验证验证码是否正确,不进行登录或注册',
|
||||||
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
})
|
||||||
);
|
@ApiResponse({ status: 200, description: '验证成功' })
|
||||||
return { message: '更新成功' };
|
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
||||||
}
|
await this.userService.verifySmsCode(
|
||||||
|
new VerifySmsCodeCommand(
|
||||||
@Post('submit-kyc')
|
dto.phoneNumber,
|
||||||
@ApiBearerAuth()
|
dto.smsCode,
|
||||||
@ApiOperation({ summary: '提交KYC认证' })
|
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||||
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
|
),
|
||||||
await this.userService.submitKYC(
|
);
|
||||||
new SubmitKYCCommand(
|
return { message: '验证成功' };
|
||||||
user.userId, dto.realName, dto.idCardNumber,
|
}
|
||||||
dto.idCardFrontUrl, dto.idCardBackUrl,
|
|
||||||
),
|
@Public()
|
||||||
);
|
@Post('register')
|
||||||
return { message: '提交成功' };
|
@ApiOperation({ summary: '用户注册(手机号)' })
|
||||||
}
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
|
async register(@Body() dto: RegisterDto) {
|
||||||
@Get('my-devices')
|
return this.userService.register(
|
||||||
@ApiBearerAuth()
|
new RegisterCommand(
|
||||||
@ApiOperation({ summary: '查看我的设备列表' })
|
dto.phoneNumber,
|
||||||
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
dto.smsCode,
|
||||||
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
dto.deviceId,
|
||||||
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId));
|
dto.deviceName,
|
||||||
}
|
dto.inviterReferralCode,
|
||||||
|
),
|
||||||
@Post('remove-device')
|
);
|
||||||
@ApiBearerAuth()
|
}
|
||||||
@ApiOperation({ summary: '移除设备' })
|
|
||||||
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
|
@Public()
|
||||||
await this.userService.removeDevice(
|
@Post('login')
|
||||||
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
@ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
|
||||||
);
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
return { message: '移除成功' };
|
async login(@Body() dto: LoginDto) {
|
||||||
}
|
return this.userService.login(
|
||||||
|
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
|
||||||
@Public()
|
);
|
||||||
@Get('by-referral-code/:code')
|
}
|
||||||
@ApiOperation({ summary: '根据推荐码查询用户' })
|
|
||||||
async getByReferralCode(@Param('code') code: string) {
|
@Public()
|
||||||
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
|
@Post('login-with-password')
|
||||||
}
|
@ApiOperation({
|
||||||
|
summary: '用户登录(手机号+密码)',
|
||||||
@Get('wallet')
|
description: '用于账号恢复,使用手机号和密码登录',
|
||||||
@ApiBearerAuth()
|
})
|
||||||
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto })
|
async loginWithPassword(@Body() dto: LoginWithPasswordDto) {
|
||||||
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto })
|
return this.userService.loginWithPassword(
|
||||||
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
dto.phoneNumber,
|
||||||
return this.userService.getWalletStatus(
|
dto.password,
|
||||||
new GetWalletStatusQuery(user.accountSequence),
|
dto.deviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('wallet/retry')
|
@Post('bind-phone')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' })
|
@ApiOperation({ summary: '绑定手机号' })
|
||||||
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
async bindPhone(
|
||||||
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
@CurrentUser() user: CurrentUserData,
|
||||||
await this.userService.retryWalletGeneration(user.userId);
|
@Body() dto: BindPhoneDto,
|
||||||
return { message: '钱包生成重试已触发,请稍后查询钱包状态' };
|
) {
|
||||||
}
|
await this.userService.bindPhoneNumber(
|
||||||
|
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
||||||
@Put('mnemonic/backup')
|
);
|
||||||
@ApiBearerAuth()
|
return { message: '绑定成功' };
|
||||||
@ApiOperation({ summary: '标记助记词已备份' })
|
}
|
||||||
@ApiResponse({ status: 200, description: '标记成功' })
|
|
||||||
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
|
@Post('set-password')
|
||||||
await this.userService.markMnemonicBackedUp(
|
@ApiBearerAuth()
|
||||||
new MarkMnemonicBackedUpCommand(user.userId),
|
@ApiOperation({
|
||||||
);
|
summary: '设置登录密码',
|
||||||
return { message: '已标记为已备份' };
|
description: '首次设置或修改登录密码',
|
||||||
}
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '密码设置成功' })
|
||||||
@Post('mnemonic/revoke')
|
async setPassword(
|
||||||
@ApiBearerAuth()
|
@CurrentUser() user: CurrentUserData,
|
||||||
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' })
|
@Body() dto: SetPasswordDto,
|
||||||
@ApiResponse({ status: 200, description: '挂失结果' })
|
) {
|
||||||
async revokeMnemonic(@CurrentUser() user: CurrentUserData, @Body() dto: RevokeMnemonicDto) {
|
await this.userService.setPassword(
|
||||||
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
new SetPasswordCommand(user.userId, dto.password),
|
||||||
}
|
);
|
||||||
|
return { message: '密码设置成功' };
|
||||||
@Post('freeze')
|
}
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' })
|
@Get('my-profile')
|
||||||
@ApiResponse({ status: 200, description: '冻结结果' })
|
@ApiBearerAuth()
|
||||||
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) {
|
@ApiOperation({ summary: '查询我的资料' })
|
||||||
return this.userService.freezeAccount(user.userId, dto.reason);
|
@ApiResponse({ status: 200, type: UserProfileResponseDto })
|
||||||
}
|
async getMyProfile(@CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
|
||||||
@Post('unfreeze')
|
}
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' })
|
@Put('update-profile')
|
||||||
@ApiResponse({ status: 200, description: '解冻结果' })
|
@ApiBearerAuth()
|
||||||
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) {
|
@ApiOperation({ summary: '更新用户资料' })
|
||||||
return this.userService.unfreezeAccount({
|
async updateProfile(
|
||||||
userId: user.userId,
|
@CurrentUser() user: CurrentUserData,
|
||||||
verifyMethod: dto.verifyMethod,
|
@Body() dto: UpdateProfileDto,
|
||||||
mnemonic: dto.mnemonic,
|
) {
|
||||||
phoneNumber: dto.phoneNumber,
|
await this.userService.updateProfile(
|
||||||
smsCode: dto.smsCode,
|
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
||||||
});
|
);
|
||||||
}
|
return { message: '更新成功' };
|
||||||
|
}
|
||||||
@Post('key-rotation/request')
|
|
||||||
@ApiBearerAuth()
|
@Post('submit-kyc')
|
||||||
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' })
|
@ApiBearerAuth()
|
||||||
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
@ApiOperation({ summary: '提交KYC认证' })
|
||||||
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) {
|
async submitKYC(
|
||||||
return this.userService.requestKeyRotation({
|
@CurrentUser() user: CurrentUserData,
|
||||||
userId: user.userId,
|
@Body() dto: SubmitKYCDto,
|
||||||
currentMnemonic: dto.currentMnemonic,
|
) {
|
||||||
reason: dto.reason,
|
await this.userService.submitKYC(
|
||||||
});
|
new SubmitKYCCommand(
|
||||||
}
|
user.userId,
|
||||||
|
dto.realName,
|
||||||
@Post('backup-codes/generate')
|
dto.idCardNumber,
|
||||||
@ApiBearerAuth()
|
dto.idCardFrontUrl,
|
||||||
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' })
|
dto.idCardBackUrl,
|
||||||
@ApiResponse({ status: 200, description: '恢复码列表' })
|
),
|
||||||
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) {
|
);
|
||||||
return this.userService.generateBackupCodes({
|
return { message: '提交成功' };
|
||||||
userId: user.userId,
|
}
|
||||||
mnemonic: dto.mnemonic,
|
|
||||||
});
|
@Get('my-devices')
|
||||||
}
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '查看我的设备列表' })
|
||||||
@Public()
|
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
||||||
@Post('recover-by-backup-code')
|
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
||||||
@ApiOperation({ summary: '使用恢复码恢复账户' })
|
return this.userService.getMyDevices(
|
||||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
new GetMyDevicesQuery(user.userId, user.deviceId),
|
||||||
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
|
);
|
||||||
return this.userService.recoverByBackupCode({
|
}
|
||||||
accountSequence: dto.accountSequence,
|
|
||||||
backupCode: dto.backupCode,
|
@Post('remove-device')
|
||||||
newDeviceId: dto.newDeviceId,
|
@ApiBearerAuth()
|
||||||
deviceName: dto.deviceName,
|
@ApiOperation({ summary: '移除设备' })
|
||||||
});
|
async removeDevice(
|
||||||
}
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: RemoveDeviceDto,
|
||||||
@Post('sms/send-withdraw-code')
|
) {
|
||||||
@ApiBearerAuth()
|
await this.userService.removeDevice(
|
||||||
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' })
|
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
||||||
@ApiResponse({ status: 200, description: '发送成功' })
|
);
|
||||||
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
return { message: '移除成功' };
|
||||||
await this.userService.sendWithdrawSmsCode(user.userId);
|
}
|
||||||
return { message: '验证码已发送' };
|
|
||||||
}
|
@Public()
|
||||||
|
@Get('by-referral-code/:code')
|
||||||
@Post('sms/verify-withdraw-code')
|
@ApiOperation({ summary: '根据推荐码查询用户' })
|
||||||
@ApiBearerAuth()
|
async getByReferralCode(@Param('code') code: string) {
|
||||||
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' })
|
return this.userService.getUserByReferralCode(
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
new GetUserByReferralCodeQuery(code),
|
||||||
async verifyWithdrawSmsCode(
|
);
|
||||||
@CurrentUser() user: CurrentUserData,
|
}
|
||||||
@Body() body: { code: string },
|
|
||||||
) {
|
@Get('wallet')
|
||||||
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code);
|
@ApiBearerAuth()
|
||||||
return { valid };
|
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
||||||
}
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
@Post('verify-password')
|
description: '钱包已就绪',
|
||||||
@ApiBearerAuth()
|
type: WalletStatusReadyResponseDto,
|
||||||
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' })
|
})
|
||||||
@ApiResponse({ status: 200, description: '验证结果' })
|
@ApiResponse({
|
||||||
async verifyPassword(
|
status: 202,
|
||||||
@CurrentUser() user: CurrentUserData,
|
description: '钱包生成中',
|
||||||
@Body() body: { password: string },
|
type: WalletStatusGeneratingResponseDto,
|
||||||
) {
|
})
|
||||||
const valid = await this.userService.verifyPassword(user.userId, body.password);
|
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
||||||
return { valid };
|
return this.userService.getWalletStatus(
|
||||||
}
|
new GetWalletStatusQuery(user.accountSequence),
|
||||||
|
);
|
||||||
@Get('users/resolve-address/:accountSequence')
|
}
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' })
|
@Post('wallet/retry')
|
||||||
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
@ApiBearerAuth()
|
||||||
@ApiResponse({ status: 404, description: '找不到用户' })
|
@ApiOperation({
|
||||||
async resolveAccountSequenceToAddress(
|
summary: '手动重试钱包生成',
|
||||||
@Param('accountSequence') accountSequence: string,
|
description: '当钱包生成失败或超时时,用户可手动触发重试',
|
||||||
) {
|
})
|
||||||
// 默认返回 KAVA 链地址(支持所有 EVM 链)
|
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
||||||
const address = await this.userService.resolveAccountSequenceToAddress(
|
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
||||||
accountSequence,
|
await this.userService.retryWalletGeneration(user.userId);
|
||||||
'KAVA',
|
return { message: '钱包生成重试已触发,请稍后查询钱包状态' };
|
||||||
);
|
}
|
||||||
return { address };
|
|
||||||
}
|
@Put('mnemonic/backup')
|
||||||
|
@ApiBearerAuth()
|
||||||
@Post('upload-avatar')
|
@ApiOperation({ summary: '标记助记词已备份' })
|
||||||
@ApiBearerAuth()
|
@ApiResponse({ status: 200, description: '标记成功' })
|
||||||
@ApiOperation({ summary: '上传用户头像' })
|
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
|
||||||
@ApiConsumes('multipart/form-data')
|
await this.userService.markMnemonicBackedUp(
|
||||||
@ApiBody({
|
new MarkMnemonicBackedUpCommand(user.userId),
|
||||||
schema: {
|
);
|
||||||
type: 'object',
|
return { message: '已标记为已备份' };
|
||||||
properties: {
|
}
|
||||||
file: {
|
|
||||||
type: 'string',
|
@Post('mnemonic/revoke')
|
||||||
format: 'binary',
|
@ApiBearerAuth()
|
||||||
description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)',
|
@ApiOperation({
|
||||||
},
|
summary: '挂失助记词',
|
||||||
},
|
description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复',
|
||||||
},
|
})
|
||||||
})
|
@ApiResponse({ status: 200, description: '挂失结果' })
|
||||||
@ApiResponse({ status: 200, description: '上传成功,返回头像URL' })
|
async revokeMnemonic(
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@CurrentUser() user: CurrentUserData,
|
||||||
async uploadAvatar(
|
@Body() dto: RevokeMnemonicDto,
|
||||||
@CurrentUser() user: CurrentUserData,
|
) {
|
||||||
@UploadedFile() file: Express.Multer.File,
|
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
||||||
) {
|
}
|
||||||
// 验证文件是否存在
|
|
||||||
if (!file) {
|
@Post('freeze')
|
||||||
throw new BadRequestException('请选择要上传的图片');
|
@ApiBearerAuth()
|
||||||
}
|
@ApiOperation({
|
||||||
|
summary: '冻结账户',
|
||||||
// 验证文件类型
|
description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作',
|
||||||
if (!this.storageService.isValidImageType(file.mimetype)) {
|
})
|
||||||
throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp');
|
@ApiResponse({ status: 200, description: '冻结结果' })
|
||||||
}
|
async freezeAccount(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
// 验证文件大小
|
@Body() dto: FreezeAccountDto,
|
||||||
if (file.size > this.storageService.maxAvatarSize) {
|
) {
|
||||||
throw new BadRequestException('图片大小不能超过 5MB');
|
return this.userService.freezeAccount(user.userId, dto.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件
|
@Post('unfreeze')
|
||||||
const result = await this.storageService.uploadAvatar(
|
@ApiBearerAuth()
|
||||||
user.userId,
|
@ApiOperation({
|
||||||
file.buffer,
|
summary: '解冻账户',
|
||||||
file.mimetype,
|
description: '验证身份后解冻账户,支持助记词或手机号验证',
|
||||||
);
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '解冻结果' })
|
||||||
// 更新用户头像URL
|
async unfreezeAccount(
|
||||||
await this.userService.updateProfile(
|
@CurrentUser() user: CurrentUserData,
|
||||||
new UpdateProfileCommand(user.userId, undefined, result.url),
|
@Body() dto: UnfreezeAccountDto,
|
||||||
);
|
) {
|
||||||
|
return this.userService.unfreezeAccount({
|
||||||
return {
|
userId: user.userId,
|
||||||
message: '上传成功',
|
verifyMethod: dto.verifyMethod,
|
||||||
avatarUrl: result.url,
|
mnemonic: dto.mnemonic,
|
||||||
};
|
phoneNumber: dto.phoneNumber,
|
||||||
}
|
smsCode: dto.smsCode,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('key-rotation/request')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '请求密钥轮换',
|
||||||
|
description: '验证当前助记词后,请求轮换 MPC 密钥对',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
||||||
|
async requestKeyRotation(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: RequestKeyRotationDto,
|
||||||
|
) {
|
||||||
|
return this.userService.requestKeyRotation({
|
||||||
|
userId: user.userId,
|
||||||
|
currentMnemonic: dto.currentMnemonic,
|
||||||
|
reason: dto.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('backup-codes/generate')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '生成恢复码',
|
||||||
|
description: '验证助记词后生成一组一次性恢复码',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '恢复码列表' })
|
||||||
|
async generateBackupCodes(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: GenerateBackupCodesDto,
|
||||||
|
) {
|
||||||
|
return this.userService.generateBackupCodes({
|
||||||
|
userId: user.userId,
|
||||||
|
mnemonic: dto.mnemonic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('recover-by-backup-code')
|
||||||
|
@ApiOperation({ summary: '使用恢复码恢复账户' })
|
||||||
|
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||||
|
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
|
||||||
|
return this.userService.recoverByBackupCode({
|
||||||
|
accountSequence: dto.accountSequence,
|
||||||
|
backupCode: dto.backupCode,
|
||||||
|
newDeviceId: dto.newDeviceId,
|
||||||
|
deviceName: dto.deviceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sms/send-withdraw-code')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '发送提取验证短信',
|
||||||
|
description: '向用户绑定的手机号发送提取验证码',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '发送成功' })
|
||||||
|
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
||||||
|
await this.userService.sendWithdrawSmsCode(user.userId);
|
||||||
|
return { message: '验证码已发送' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sms/verify-withdraw-code')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '验证提取短信验证码',
|
||||||
|
description: '验证提取操作的短信验证码',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
|
async verifyWithdrawSmsCode(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() body: { code: string },
|
||||||
|
) {
|
||||||
|
const valid = await this.userService.verifyWithdrawSmsCode(
|
||||||
|
user.userId,
|
||||||
|
body.code,
|
||||||
|
);
|
||||||
|
return { valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('verify-password')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '验证登录密码',
|
||||||
|
description: '验证用户的登录密码,用于敏感操作二次验证',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '验证结果' })
|
||||||
|
async verifyPassword(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() body: { password: string },
|
||||||
|
) {
|
||||||
|
const valid = await this.userService.verifyPassword(
|
||||||
|
user.userId,
|
||||||
|
body.password,
|
||||||
|
);
|
||||||
|
return { valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users/resolve-address/:accountSequence')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '解析充值ID到区块链地址',
|
||||||
|
description: '通过用户的 accountSequence 获取其区块链钱包地址',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
||||||
|
@ApiResponse({ status: 404, description: '找不到用户' })
|
||||||
|
async resolveAccountSequenceToAddress(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
// 默认返回 KAVA 链地址(支持所有 EVM 链)
|
||||||
|
const address = await this.userService.resolveAccountSequenceToAddress(
|
||||||
|
accountSequence,
|
||||||
|
'KAVA',
|
||||||
|
);
|
||||||
|
return { address };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload-avatar')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '上传用户头像' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary',
|
||||||
|
description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '上传成功,返回头像URL' })
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async uploadAvatar(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
// 验证文件是否存在
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('请选择要上传的图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (!this.storageService.isValidImageType(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'不支持的图片格式,请使用 jpg, png, gif 或 webp',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
if (file.size > this.storageService.maxAvatarSize) {
|
||||||
|
throw new BadRequestException('图片大小不能超过 5MB');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
const result = await this.storageService.uploadAvatar(
|
||||||
|
user.userId,
|
||||||
|
file.buffer,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新用户头像URL
|
||||||
|
await this.userService.updateProfile(
|
||||||
|
new UpdateProfileCommand(user.userId, undefined, result.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: '上传成功',
|
||||||
|
avatarUrl: result.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,367 +1,398 @@
|
||||||
// Request DTOs
|
// Request DTOs
|
||||||
export * from './request';
|
export * from './request';
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
export * from './response';
|
export * from './response';
|
||||||
|
|
||||||
// 其他通用DTOs
|
// 其他通用DTOs
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator';
|
import {
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
|
IsOptional,
|
||||||
export class AutoLoginDto {
|
IsNotEmpty,
|
||||||
@ApiProperty()
|
Matches,
|
||||||
@IsString()
|
IsEnum,
|
||||||
@IsNotEmpty()
|
IsNumber,
|
||||||
refreshToken: string;
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
@ApiProperty()
|
|
||||||
@IsString()
|
export class AutoLoginDto {
|
||||||
@IsNotEmpty()
|
@ApiProperty()
|
||||||
deviceId: string;
|
@IsString()
|
||||||
}
|
@IsNotEmpty()
|
||||||
|
refreshToken: string;
|
||||||
export class SendSmsCodeDto {
|
|
||||||
@ApiProperty({ example: '13800138000' })
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
@IsNotEmpty()
|
||||||
phoneNumber: string;
|
deviceId: string;
|
||||||
|
}
|
||||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
|
||||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
export class SendSmsCodeDto {
|
||||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
@ApiProperty({ example: '13800138000' })
|
||||||
}
|
@IsString()
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
export class RegisterDto {
|
phoneNumber: string;
|
||||||
@ApiProperty({ example: '13800138000' })
|
|
||||||
@IsString()
|
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
||||||
phoneNumber: string;
|
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
||||||
|
}
|
||||||
@ApiProperty({ example: '123456' })
|
|
||||||
@IsString()
|
export class RegisterDto {
|
||||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
@ApiProperty({ example: '13800138000' })
|
||||||
smsCode: string;
|
@IsString()
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
@ApiProperty()
|
phoneNumber: string;
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
@ApiProperty({ example: '123456' })
|
||||||
deviceId: string;
|
@IsString()
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||||
@ApiPropertyOptional()
|
smsCode: string;
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@ApiProperty()
|
||||||
deviceName?: string;
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
@ApiPropertyOptional()
|
deviceId: string;
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@ApiPropertyOptional()
|
||||||
inviterReferralCode?: string;
|
@IsOptional()
|
||||||
}
|
@IsString()
|
||||||
|
deviceName?: string;
|
||||||
export class LoginDto {
|
|
||||||
@ApiProperty({ example: '13800138000' })
|
@ApiPropertyOptional()
|
||||||
@IsString()
|
@IsOptional()
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
@IsString()
|
||||||
phoneNumber: string;
|
inviterReferralCode?: string;
|
||||||
|
}
|
||||||
@ApiProperty({ example: '123456' })
|
|
||||||
@IsString()
|
export class LoginDto {
|
||||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
@ApiProperty({ example: '13800138000' })
|
||||||
smsCode: string;
|
@IsString()
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
@ApiProperty()
|
phoneNumber: string;
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
@ApiProperty({ example: '123456' })
|
||||||
deviceId: string;
|
@IsString()
|
||||||
}
|
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||||
|
smsCode: string;
|
||||||
export class AdminLoginDto {
|
|
||||||
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
@IsNotEmpty()
|
||||||
email: string;
|
deviceId: string;
|
||||||
|
}
|
||||||
@ApiProperty({ example: 'password123', description: '密码' })
|
|
||||||
@IsString()
|
export class AdminLoginDto {
|
||||||
@IsNotEmpty({ message: '密码不能为空' })
|
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
|
||||||
password: string;
|
@IsString()
|
||||||
}
|
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||||
|
email: string;
|
||||||
export class AdminLoginResponseDto {
|
|
||||||
@ApiProperty()
|
@ApiProperty({ example: 'password123', description: '密码' })
|
||||||
userId: string;
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
@ApiProperty({ description: '管理员邮箱' })
|
password: string;
|
||||||
email: string;
|
}
|
||||||
|
|
||||||
@ApiProperty({ description: '管理员昵称' })
|
export class AdminLoginResponseDto {
|
||||||
nickname: string;
|
@ApiProperty()
|
||||||
|
userId: string;
|
||||||
@ApiProperty({ description: '角色' })
|
|
||||||
role: string;
|
@ApiProperty({ description: '管理员邮箱' })
|
||||||
|
email: string;
|
||||||
@ApiProperty()
|
|
||||||
accessToken: string;
|
@ApiProperty({ description: '管理员昵称' })
|
||||||
|
nickname: string;
|
||||||
@ApiProperty()
|
|
||||||
refreshToken: string;
|
@ApiProperty({ description: '角色' })
|
||||||
}
|
role: string;
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
@ApiProperty()
|
||||||
@ApiPropertyOptional()
|
accessToken: string;
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@ApiProperty()
|
||||||
nickname?: string;
|
refreshToken: string;
|
||||||
|
}
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
export class UpdateProfileDto {
|
||||||
@IsString()
|
@ApiPropertyOptional()
|
||||||
avatarUrl?: string;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
@ApiPropertyOptional()
|
nickname?: string;
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@ApiPropertyOptional()
|
||||||
address?: string;
|
@IsOptional()
|
||||||
}
|
@IsString()
|
||||||
|
avatarUrl?: string;
|
||||||
export class BindWalletDto {
|
|
||||||
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
|
@ApiPropertyOptional()
|
||||||
@IsEnum(['KAVA', 'DST', 'BSC'])
|
@IsOptional()
|
||||||
chainType: string;
|
@IsString()
|
||||||
|
address?: string;
|
||||||
@ApiProperty()
|
}
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
export class BindWalletDto {
|
||||||
address: string;
|
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
|
||||||
}
|
@IsEnum(['KAVA', 'DST', 'BSC'])
|
||||||
|
chainType: string;
|
||||||
export class RemoveDeviceDto {
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
deviceId: string;
|
address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response DTOs
|
export class RemoveDeviceDto {
|
||||||
export class AutoCreateAccountResponseDto {
|
@ApiProperty()
|
||||||
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@IsString()
|
||||||
userSerialNum: string;
|
@IsNotEmpty()
|
||||||
|
deviceId: string;
|
||||||
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
}
|
||||||
referralCode: string;
|
|
||||||
|
// Response DTOs
|
||||||
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
|
export class AutoCreateAccountResponseDto {
|
||||||
username: string;
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
|
description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
avatarSvg: string;
|
})
|
||||||
|
userSerialNum: string;
|
||||||
@ApiProperty({ description: '访问令牌' })
|
|
||||||
accessToken: string;
|
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
||||||
|
referralCode: string;
|
||||||
@ApiProperty({ description: '刷新令牌' })
|
|
||||||
refreshToken: string;
|
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
|
||||||
}
|
username: string;
|
||||||
|
|
||||||
export class RecoverAccountResponseDto {
|
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
|
||||||
@ApiProperty()
|
avatarSvg: string;
|
||||||
userId: string;
|
|
||||||
|
@ApiProperty({ description: '访问令牌' })
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
accessToken: string;
|
||||||
accountSequence: string;
|
|
||||||
|
@ApiProperty({ description: '刷新令牌' })
|
||||||
@ApiProperty()
|
refreshToken: string;
|
||||||
nickname: string;
|
}
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
export class RecoverAccountResponseDto {
|
||||||
avatarUrl: string | null;
|
@ApiProperty()
|
||||||
|
userId: string;
|
||||||
@ApiProperty()
|
|
||||||
referralCode: string;
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
@ApiProperty()
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
accessToken: string;
|
})
|
||||||
|
accountSequence: string;
|
||||||
@ApiProperty()
|
|
||||||
refreshToken: string;
|
@ApiProperty()
|
||||||
}
|
nickname: string;
|
||||||
|
|
||||||
// 钱包地址响应
|
@ApiProperty({ nullable: true })
|
||||||
export class WalletAddressesDto {
|
avatarUrl: string | null;
|
||||||
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
|
|
||||||
kava: string;
|
@ApiProperty()
|
||||||
|
referralCode: string;
|
||||||
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
|
|
||||||
dst: string;
|
@ApiProperty()
|
||||||
|
accessToken: string;
|
||||||
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
|
|
||||||
bsc: string;
|
@ApiProperty()
|
||||||
}
|
refreshToken: string;
|
||||||
|
}
|
||||||
// 钱包状态响应 (就绪)
|
|
||||||
export class WalletStatusReadyResponseDto {
|
// 钱包地址响应
|
||||||
@ApiProperty({ example: 'ready', description: '钱包状态' })
|
export class WalletAddressesDto {
|
||||||
status: 'ready';
|
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
|
||||||
|
kava: string;
|
||||||
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
|
||||||
walletAddresses: WalletAddressesDto;
|
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
|
||||||
|
dst: string;
|
||||||
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
|
|
||||||
mnemonic: string;
|
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
|
||||||
}
|
bsc: string;
|
||||||
|
}
|
||||||
// 钱包状态响应 (生成中)
|
|
||||||
export class WalletStatusGeneratingResponseDto {
|
// 钱包状态响应 (就绪)
|
||||||
@ApiProperty({ example: 'generating', description: '钱包状态' })
|
export class WalletStatusReadyResponseDto {
|
||||||
status: 'generating';
|
@ApiProperty({ example: 'ready', description: '钱包状态' })
|
||||||
}
|
status: 'ready';
|
||||||
|
|
||||||
export class LoginResponseDto {
|
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
||||||
@ApiProperty()
|
walletAddresses: WalletAddressesDto;
|
||||||
userId: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
example: 'word1 word2 ... word12',
|
||||||
accountSequence: string;
|
description: '助记词 (12词)',
|
||||||
|
})
|
||||||
@ApiProperty()
|
mnemonic: string;
|
||||||
accessToken: string;
|
}
|
||||||
|
|
||||||
@ApiProperty()
|
// 钱包状态响应 (生成中)
|
||||||
refreshToken: string;
|
export class WalletStatusGeneratingResponseDto {
|
||||||
}
|
@ApiProperty({ example: 'generating', description: '钱包状态' })
|
||||||
|
status: 'generating';
|
||||||
// ============ Referral DTOs ============
|
}
|
||||||
|
|
||||||
export class GenerateReferralLinkDto {
|
export class LoginResponseDto {
|
||||||
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
|
@ApiProperty()
|
||||||
@IsOptional()
|
userId: string;
|
||||||
@IsString()
|
|
||||||
channel?: string;
|
@ApiProperty({
|
||||||
|
example: 'D2512110001',
|
||||||
@ApiPropertyOptional({ description: '活动ID' })
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
@IsOptional()
|
})
|
||||||
@IsString()
|
accountSequence: string;
|
||||||
campaignId?: string;
|
|
||||||
}
|
@ApiProperty()
|
||||||
|
accessToken: string;
|
||||||
export class MeResponseDto {
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
refreshToken: string;
|
||||||
|
}
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
|
||||||
accountSequence: string;
|
// ============ Referral DTOs ============
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
export class GenerateReferralLinkDto {
|
||||||
phoneNumber: string | null;
|
@ApiPropertyOptional({
|
||||||
|
description: '渠道标识: wechat, telegram, twitter 等',
|
||||||
@ApiProperty()
|
})
|
||||||
nickname: string;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
@ApiProperty({ nullable: true })
|
channel?: string;
|
||||||
avatarUrl: string | null;
|
|
||||||
|
@ApiPropertyOptional({ description: '活动ID' })
|
||||||
@ApiProperty({ description: '推荐码' })
|
@IsOptional()
|
||||||
referralCode: string;
|
@IsString()
|
||||||
|
campaignId?: string;
|
||||||
@ApiProperty({ description: '完整推荐链接' })
|
}
|
||||||
referralLink: string;
|
|
||||||
|
export class MeResponseDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true })
|
@ApiProperty()
|
||||||
inviterSequence: string | null;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '钱包地址列表' })
|
@ApiProperty({
|
||||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
@ApiProperty()
|
})
|
||||||
kycStatus: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
status: string;
|
phoneNumber: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
registeredAt: Date;
|
nickname: string;
|
||||||
}
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
export class ReferralValidationResponseDto {
|
avatarUrl: string | null;
|
||||||
@ApiProperty({ description: '推荐码是否有效' })
|
|
||||||
valid: boolean;
|
@ApiProperty({ description: '推荐码' })
|
||||||
|
referralCode: string;
|
||||||
@ApiPropertyOptional()
|
|
||||||
referralCode?: string;
|
@ApiProperty({ description: '完整推荐链接' })
|
||||||
|
referralLink: string;
|
||||||
@ApiPropertyOptional({ description: '邀请人信息' })
|
|
||||||
inviterInfo?: {
|
@ApiProperty({
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
example: 'D2512110001',
|
||||||
nickname: string;
|
description: '推荐人序列号',
|
||||||
avatarUrl: string | null;
|
nullable: true,
|
||||||
};
|
})
|
||||||
|
inviterSequence: string | null;
|
||||||
@ApiPropertyOptional({ description: '错误信息' })
|
|
||||||
message?: string;
|
@ApiProperty({ description: '钱包地址列表' })
|
||||||
}
|
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||||
|
|
||||||
export class ReferralLinkResponseDto {
|
@ApiProperty()
|
||||||
@ApiProperty()
|
kycStatus: string;
|
||||||
linkId: string;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty()
|
status: string;
|
||||||
referralCode: string;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ description: '短链' })
|
registeredAt: Date;
|
||||||
shortUrl: string;
|
}
|
||||||
|
|
||||||
@ApiProperty({ description: '完整链接' })
|
export class ReferralValidationResponseDto {
|
||||||
fullUrl: string;
|
@ApiProperty({ description: '推荐码是否有效' })
|
||||||
|
valid: boolean;
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
channel: string | null;
|
@ApiPropertyOptional()
|
||||||
|
referralCode?: string;
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
campaignId: string | null;
|
@ApiPropertyOptional({ description: '邀请人信息' })
|
||||||
|
inviterInfo?: {
|
||||||
@ApiProperty()
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
createdAt: Date;
|
nickname: string;
|
||||||
}
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
export class InviteRecordDto {
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiPropertyOptional({ description: '错误信息' })
|
||||||
accountSequence: string;
|
message?: string;
|
||||||
|
}
|
||||||
@ApiProperty()
|
|
||||||
nickname: string;
|
export class ReferralLinkResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ nullable: true })
|
linkId: string;
|
||||||
avatarUrl: string | null;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty()
|
referralCode: string;
|
||||||
registeredAt: Date;
|
|
||||||
|
@ApiProperty({ description: '短链' })
|
||||||
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
|
shortUrl: string;
|
||||||
level: number;
|
|
||||||
}
|
@ApiProperty({ description: '完整链接' })
|
||||||
|
fullUrl: string;
|
||||||
export class ReferralStatsResponseDto {
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
referralCode: string;
|
channel: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '总邀请人数' })
|
@ApiProperty({ nullable: true })
|
||||||
totalInvites: number;
|
campaignId: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '直接邀请人数' })
|
@ApiProperty()
|
||||||
directInvites: number;
|
createdAt: Date;
|
||||||
|
}
|
||||||
@ApiProperty({ description: '间接邀请人数 (二级)' })
|
|
||||||
indirectInvites: number;
|
export class InviteRecordDto {
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({ description: '今日邀请' })
|
example: 'D2512110001',
|
||||||
todayInvites: number;
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
|
})
|
||||||
@ApiProperty({ description: '本周邀请' })
|
accountSequence: string;
|
||||||
thisWeekInvites: number;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ description: '本月邀请' })
|
nickname: string;
|
||||||
thisMonthInvites: number;
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
|
avatarUrl: string | null;
|
||||||
recentInvites: InviteRecordDto[];
|
|
||||||
}
|
@ApiProperty()
|
||||||
|
registeredAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReferralStatsResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
referralCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总邀请人数' })
|
||||||
|
totalInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直接邀请人数' })
|
||||||
|
directInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '间接邀请人数 (二级)' })
|
||||||
|
indirectInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '今日邀请' })
|
||||||
|
todayInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '本周邀请' })
|
||||||
|
thisWeekInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '本月邀请' })
|
||||||
|
thisMonthInvites: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
|
||||||
|
recentInvites: InviteRecordDto[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,53 @@
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches, IsObject } from 'class-validator';
|
import {
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
|
IsOptional,
|
||||||
/**
|
IsNotEmpty,
|
||||||
* 设备信息 - 接收任意 JSON 对象
|
Matches,
|
||||||
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
IsObject,
|
||||||
*/
|
} from 'class-validator';
|
||||||
export interface DeviceNameDto {
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
model?: string; // 设备型号
|
|
||||||
platform?: string; // 平台: ios, android, web
|
/**
|
||||||
osVersion?: string; // 系统版本
|
* 设备信息 - 接收任意 JSON 对象
|
||||||
brand?: string; // 品牌
|
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
||||||
manufacturer?: string; // 厂商
|
*/
|
||||||
device?: string; // 设备名
|
export interface DeviceNameDto {
|
||||||
product?: string; // 产品名
|
model?: string; // 设备型号
|
||||||
hardware?: string; // 硬件名
|
platform?: string; // 平台: ios, android, web
|
||||||
sdkInt?: number; // SDK 版本 (Android)
|
osVersion?: string; // 系统版本
|
||||||
isPhysicalDevice?: boolean; // 是否真机
|
brand?: string; // 品牌
|
||||||
[key: string]: unknown; // 允许其他字段
|
manufacturer?: string; // 厂商
|
||||||
}
|
device?: string; // 设备名
|
||||||
|
product?: string; // 产品名
|
||||||
export class AutoCreateAccountDto {
|
hardware?: string; // 硬件名
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' })
|
sdkInt?: number; // SDK 版本 (Android)
|
||||||
@IsString()
|
isPhysicalDevice?: boolean; // 是否真机
|
||||||
@IsNotEmpty()
|
[key: string]: unknown; // 允许其他字段
|
||||||
deviceId: string;
|
}
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
export class AutoCreateAccountDto {
|
||||||
description: '设备信息 (JSON 对象)',
|
@ApiProperty({
|
||||||
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
})
|
description: '设备唯一标识',
|
||||||
@IsOptional()
|
})
|
||||||
@IsObject()
|
@IsString()
|
||||||
deviceName?: DeviceNameDto;
|
@IsNotEmpty()
|
||||||
|
deviceId: string;
|
||||||
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
|
|
||||||
@IsOptional()
|
@ApiPropertyOptional({
|
||||||
@IsString()
|
description: '设备信息 (JSON 对象)',
|
||||||
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' },
|
||||||
inviterReferralCode?: string;
|
})
|
||||||
}
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
deviceName?: DeviceNameDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'RWAABC1234',
|
||||||
|
description: '邀请人推荐码 (6-20位大写字母和数字)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
||||||
|
inviterReferralCode?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { IsString, Matches } from 'class-validator';
|
import { IsString, Matches } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class BindPhoneDto {
|
export class BindPhoneDto {
|
||||||
@ApiProperty({ example: '13800138000' })
|
@ApiProperty({ example: '13800138000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456' })
|
@ApiProperty({ example: '123456' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||||
smsCode: string;
|
smsCode: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class GenerateBackupCodesDto {
|
export class GenerateBackupCodesDto {
|
||||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
@ApiProperty({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '当前助记词(验证身份用)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
export * from './auto-create-account.dto';
|
export * from './auto-create-account.dto';
|
||||||
export * from './recover-by-mnemonic.dto';
|
export * from './recover-by-mnemonic.dto';
|
||||||
export * from './recover-by-phone.dto';
|
export * from './recover-by-phone.dto';
|
||||||
export * from './bind-phone.dto';
|
export * from './bind-phone.dto';
|
||||||
export * from './submit-kyc.dto';
|
export * from './submit-kyc.dto';
|
||||||
export * from './revoke-mnemonic.dto';
|
export * from './revoke-mnemonic.dto';
|
||||||
export * from './freeze-account.dto';
|
export * from './freeze-account.dto';
|
||||||
export * from './unfreeze-account.dto';
|
export * from './unfreeze-account.dto';
|
||||||
export * from './request-key-rotation.dto';
|
export * from './request-key-rotation.dto';
|
||||||
export * from './generate-backup-codes.dto';
|
export * from './generate-backup-codes.dto';
|
||||||
export * from './recover-by-backup-code.dto';
|
export * from './recover-by-backup-code.dto';
|
||||||
export * from './verify-sms-code.dto';
|
export * from './verify-sms-code.dto';
|
||||||
export * from './set-password.dto';
|
export * from './set-password.dto';
|
||||||
|
export * from './login-with-password.dto';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号+密码登录 DTO
|
||||||
|
*/
|
||||||
|
export class LoginWithPasswordDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '手机号',
|
||||||
|
example: '13800138000',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '手机号不能为空' })
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||||
|
phoneNumber!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '登录密码',
|
||||||
|
example: 'password123',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
|
@MinLength(6, { message: '密码至少6位' })
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '设备ID',
|
||||||
|
example: 'device-uuid-12345',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '设备ID不能为空' })
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,9 @@ export class RecoverByBackupCodeDto {
|
||||||
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
|
@ApiProperty({ example: 'ABCD-1234-EFGH', description: '恢复码' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供恢复码' })
|
@IsNotEmpty({ message: '请提供恢复码' })
|
||||||
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, { message: '恢复码格式不正确' })
|
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, {
|
||||||
|
message: '恢复码格式不正确',
|
||||||
|
})
|
||||||
backupCode: string;
|
backupCode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,32 @@
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RecoverByMnemonicDto {
|
export class RecoverByMnemonicDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
@IsString()
|
example: 'D2512110001',
|
||||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
accountSequence: string;
|
})
|
||||||
|
@IsString()
|
||||||
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' })
|
@Matches(/^D\d{11}$/, {
|
||||||
@IsString()
|
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||||
@IsNotEmpty()
|
})
|
||||||
mnemonic: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({
|
||||||
@IsString()
|
example:
|
||||||
@IsNotEmpty()
|
'abandon ability able about above absent absorb abstract absurd abuse access accident',
|
||||||
newDeviceId: string;
|
})
|
||||||
|
@IsString()
|
||||||
@ApiPropertyOptional()
|
@IsNotEmpty()
|
||||||
@IsOptional()
|
mnemonic: string;
|
||||||
@IsString()
|
|
||||||
deviceName?: string;
|
@ApiProperty()
|
||||||
}
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
newDeviceId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceName?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,34 @@
|
||||||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RecoverByPhoneDto {
|
export class RecoverByPhoneDto {
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
@IsString()
|
example: 'D2512110001',
|
||||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
accountSequence: string;
|
})
|
||||||
|
@IsString()
|
||||||
@ApiProperty({ example: '13800138000' })
|
@Matches(/^D\d{11}$/, {
|
||||||
@IsString()
|
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
})
|
||||||
phoneNumber: string;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456' })
|
@ApiProperty({ example: '13800138000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
smsCode: string;
|
phoneNumber: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ example: '123456' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||||
newDeviceId: string;
|
smsCode: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiProperty()
|
||||||
@IsOptional()
|
@IsString()
|
||||||
@IsString()
|
@IsNotEmpty()
|
||||||
deviceName?: string;
|
newDeviceId: string;
|
||||||
}
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceName?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RequestKeyRotationDto {
|
export class RequestKeyRotationDto {
|
||||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
@ApiProperty({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '当前助记词(验证身份用)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||||
currentMnemonic: string;
|
currentMnemonic: string;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,30 @@
|
||||||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SubmitKycDto {
|
export class SubmitKycDto {
|
||||||
@ApiProperty({ example: '张三' })
|
@ApiProperty({ example: '张三' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
realName: string;
|
realName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '110101199001011234' })
|
@ApiProperty({ example: '110101199001011234' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/, { message: '身份证号格式错误' })
|
@Matches(
|
||||||
idCardNumber: string;
|
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/,
|
||||||
|
{ message: '身份证号格式错误' },
|
||||||
@ApiProperty()
|
)
|
||||||
@IsString()
|
idCardNumber: string;
|
||||||
@IsNotEmpty()
|
|
||||||
idCardFrontUrl: string;
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
@ApiProperty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
idCardFrontUrl: string;
|
||||||
@IsNotEmpty()
|
|
||||||
idCardBackUrl: string;
|
@ApiProperty()
|
||||||
}
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
// 导出别名以兼容不同命名风格
|
idCardBackUrl: string;
|
||||||
export { SubmitKycDto as SubmitKYCDto };
|
}
|
||||||
|
|
||||||
|
// 导出别名以兼容不同命名风格
|
||||||
|
export { SubmitKycDto as SubmitKYCDto };
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,34 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UnfreezeAccountDto {
|
export class UnfreezeAccountDto {
|
||||||
@ApiProperty({ example: '确认账户安全', description: '解冻验证方式: mnemonic 或 phone' })
|
@ApiProperty({
|
||||||
|
example: '确认账户安全',
|
||||||
|
description: '解冻验证方式: mnemonic 或 phone',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
verifyMethod: 'mnemonic' | 'phone';
|
verifyMethod: 'mnemonic' | 'phone';
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'abandon abandon ...', description: '助记词 (verifyMethod=mnemonic时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: 'abandon abandon ...',
|
||||||
|
description: '助记词 (verifyMethod=mnemonic时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
mnemonic?: string;
|
mnemonic?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '+8613800138000', description: '手机号 (verifyMethod=phone时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: '+8613800138000',
|
||||||
|
description: '手机号 (verifyMethod=phone时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '123456', description: '短信验证码 (verifyMethod=phone时必填)' })
|
@ApiPropertyOptional({
|
||||||
|
example: '123456',
|
||||||
|
description: '短信验证码 (verifyMethod=phone时必填)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
smsCode?: string;
|
smsCode?: string;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export class VerifySmsCodeDto {
|
||||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], { message: '无效的验证码类型' })
|
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
|
||||||
|
message: '无效的验证码类型',
|
||||||
|
})
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class DeviceDto {
|
export class DeviceDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
addedAt: Date;
|
addedAt: Date;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
lastActiveAt: Date;
|
lastActiveAt: Date;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出别名以兼容其他命名方式
|
// 导出别名以兼容其他命名方式
|
||||||
export { DeviceDto as DeviceResponseDto };
|
export { DeviceDto as DeviceResponseDto };
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export * from './user-profile.dto';
|
export * from './user-profile.dto';
|
||||||
export * from './device.dto';
|
export * from './device.dto';
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,61 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class WalletAddressDto {
|
export class WalletAddressDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
chainType: string;
|
chainType: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
address: string;
|
address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KycInfoDto {
|
export class KycInfoDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
realName: string;
|
realName: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
idCardNumber: string;
|
idCardNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserProfileDto {
|
export class UserProfileDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
@ApiProperty({
|
||||||
accountSequence: string;
|
example: 'D2512110001',
|
||||||
|
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||||
@ApiProperty({ nullable: true })
|
})
|
||||||
phoneNumber: string | null;
|
accountSequence: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
nickname: string;
|
phoneNumber: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty()
|
||||||
avatarUrl: string | null;
|
nickname: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
referralCode: string;
|
avatarUrl: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: [WalletAddressDto] })
|
@ApiProperty()
|
||||||
walletAddresses: WalletAddressDto[];
|
referralCode: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ type: [WalletAddressDto] })
|
||||||
kycStatus: string;
|
walletAddresses: WalletAddressDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: KycInfoDto, nullable: true })
|
@ApiProperty()
|
||||||
kycInfo: KycInfoDto | null;
|
kycStatus: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ type: KycInfoDto, nullable: true })
|
||||||
status: string;
|
kycInfo: KycInfoDto | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
registeredAt: Date;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty()
|
||||||
lastLoginAt: Date | null;
|
registeredAt: Date;
|
||||||
}
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
// 导出别名以兼容其他命名方式
|
lastLoginAt: Date | null;
|
||||||
export { UserProfileDto as UserProfileResponseDto };
|
}
|
||||||
|
|
||||||
|
// 导出别名以兼容其他命名方式
|
||||||
|
export { UserProfileDto as UserProfileResponseDto };
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,55 @@
|
||||||
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator';
|
import {
|
||||||
|
ValidatorConstraint,
|
||||||
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
ValidatorConstraintInterface,
|
||||||
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
ValidationArguments,
|
||||||
validate(phone: string, args: ValidationArguments): boolean {
|
registerDecorator,
|
||||||
return /^1[3-9]\d{9}$/.test(phone);
|
ValidationOptions,
|
||||||
}
|
} from 'class-validator';
|
||||||
|
|
||||||
defaultMessage(args: ValidationArguments): string {
|
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
||||||
return '手机号格式错误';
|
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
||||||
}
|
validate(phone: string, args: ValidationArguments): boolean {
|
||||||
}
|
return /^1[3-9]\d{9}$/.test(phone);
|
||||||
|
}
|
||||||
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
|
||||||
return function (object: Object, propertyName: string) {
|
defaultMessage(args: ValidationArguments): string {
|
||||||
registerDecorator({
|
return '手机号格式错误';
|
||||||
target: object.constructor,
|
}
|
||||||
propertyName: propertyName,
|
}
|
||||||
options: validationOptions,
|
|
||||||
constraints: [],
|
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
||||||
validator: IsChinesePhoneConstraint,
|
return function (object: object, propertyName: string) {
|
||||||
});
|
registerDecorator({
|
||||||
};
|
target: object.constructor,
|
||||||
}
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
constraints: [],
|
||||||
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
validator: IsChinesePhoneConstraint,
|
||||||
validate(idCard: string, args: ValidationArguments): boolean {
|
});
|
||||||
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(args: ValidationArguments): string {
|
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
||||||
return '身份证号格式错误';
|
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
||||||
}
|
validate(idCard: string, args: ValidationArguments): boolean {
|
||||||
}
|
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
|
idCard,
|
||||||
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
);
|
||||||
return function (object: Object, propertyName: string) {
|
}
|
||||||
registerDecorator({
|
|
||||||
target: object.constructor,
|
defaultMessage(args: ValidationArguments): string {
|
||||||
propertyName: propertyName,
|
return '身份证号格式错误';
|
||||||
options: validationOptions,
|
}
|
||||||
constraints: [],
|
}
|
||||||
validator: IsChineseIdCardConstraint,
|
|
||||||
});
|
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
||||||
};
|
return function (object: object, propertyName: string) {
|
||||||
}
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsChineseIdCardConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,186 @@
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module, Global } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config';
|
import {
|
||||||
|
appConfig,
|
||||||
// Controllers
|
databaseConfig,
|
||||||
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
jwtConfig,
|
||||||
import { HealthController } from '@/api/controllers/health.controller';
|
redisConfig,
|
||||||
import { ReferralsController } from '@/api/controllers/referrals.controller';
|
kafkaConfig,
|
||||||
import { AuthController } from '@/api/controllers/auth.controller';
|
smsConfig,
|
||||||
import { TotpController } from '@/api/controllers/totp.controller';
|
walletConfig,
|
||||||
|
} from '@/config';
|
||||||
// Application Services
|
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
// Controllers
|
||||||
import { TokenService } from '@/application/services/token.service';
|
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
||||||
import { TotpService } from '@/application/services/totp.service';
|
import { HealthController } from '@/api/controllers/health.controller';
|
||||||
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
|
import { ReferralsController } from '@/api/controllers/referrals.controller';
|
||||||
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
|
import { AuthController } from '@/api/controllers/auth.controller';
|
||||||
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
|
import { TotpController } from '@/api/controllers/totp.controller';
|
||||||
|
|
||||||
// Domain Services
|
// Application Services
|
||||||
import {
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
AccountSequenceGeneratorService, UserValidatorService,
|
import { TokenService } from '@/application/services/token.service';
|
||||||
} from '@/domain/services';
|
import { TotpService } from '@/application/services/totp.service';
|
||||||
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
|
||||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
|
||||||
|
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
|
||||||
// Infrastructure
|
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
// Domain Services
|
||||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
import {
|
||||||
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
|
AccountSequenceGeneratorService,
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
UserValidatorService,
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
} from '@/domain/services';
|
||||||
import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service';
|
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
|
||||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
// Infrastructure
|
||||||
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||||
|
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
|
||||||
// Shared
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service';
|
||||||
|
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
||||||
// ============ Infrastructure Module ============
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||||
@Global()
|
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||||
@Module({
|
import {
|
||||||
imports: [
|
MpcClientService,
|
||||||
ConfigModule,
|
MpcWalletService,
|
||||||
HttpModule.register({
|
} from '@/infrastructure/external/mpc';
|
||||||
timeout: 300000,
|
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||||
maxRedirects: 5,
|
|
||||||
}),
|
// Shared
|
||||||
],
|
import {
|
||||||
providers: [
|
GlobalExceptionFilter,
|
||||||
PrismaService,
|
TransformInterceptor,
|
||||||
RedisService,
|
} from '@/shared/filters/global-exception.filter';
|
||||||
EventPublisherService,
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
MpcEventConsumerService,
|
|
||||||
BlockchainEventConsumerService,
|
// ============ Infrastructure Module ============
|
||||||
SmsService,
|
@Global()
|
||||||
MpcClientService,
|
@Module({
|
||||||
MpcWalletService,
|
imports: [
|
||||||
BlockchainClientService,
|
ConfigModule,
|
||||||
StorageService,
|
HttpModule.register({
|
||||||
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
|
timeout: 300000,
|
||||||
],
|
maxRedirects: 5,
|
||||||
exports: [
|
}),
|
||||||
PrismaService,
|
],
|
||||||
RedisService,
|
providers: [
|
||||||
EventPublisherService,
|
PrismaService,
|
||||||
MpcEventConsumerService,
|
RedisService,
|
||||||
BlockchainEventConsumerService,
|
EventPublisherService,
|
||||||
SmsService,
|
MpcEventConsumerService,
|
||||||
MpcClientService,
|
BlockchainEventConsumerService,
|
||||||
MpcWalletService,
|
SmsService,
|
||||||
BlockchainClientService,
|
MpcClientService,
|
||||||
StorageService,
|
MpcWalletService,
|
||||||
MPC_KEY_SHARE_REPOSITORY,
|
BlockchainClientService,
|
||||||
],
|
StorageService,
|
||||||
})
|
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
|
||||||
export class InfrastructureModule {}
|
],
|
||||||
|
exports: [
|
||||||
// ============ Domain Module ============
|
PrismaService,
|
||||||
@Module({
|
RedisService,
|
||||||
imports: [InfrastructureModule],
|
EventPublisherService,
|
||||||
providers: [
|
MpcEventConsumerService,
|
||||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
BlockchainEventConsumerService,
|
||||||
AccountSequenceGeneratorService,
|
SmsService,
|
||||||
UserValidatorService,
|
MpcClientService,
|
||||||
],
|
MpcWalletService,
|
||||||
exports: [
|
BlockchainClientService,
|
||||||
USER_ACCOUNT_REPOSITORY,
|
StorageService,
|
||||||
AccountSequenceGeneratorService,
|
MPC_KEY_SHARE_REPOSITORY,
|
||||||
UserValidatorService,
|
],
|
||||||
],
|
})
|
||||||
})
|
export class InfrastructureModule {}
|
||||||
export class DomainModule {}
|
|
||||||
|
// ============ Domain Module ============
|
||||||
// ============ Application Module ============
|
@Module({
|
||||||
@Module({
|
imports: [InfrastructureModule],
|
||||||
imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()],
|
providers: [
|
||||||
providers: [
|
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||||
UserApplicationService,
|
AccountSequenceGeneratorService,
|
||||||
TokenService,
|
UserValidatorService,
|
||||||
TotpService,
|
],
|
||||||
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
|
exports: [
|
||||||
BlockchainWalletHandler,
|
USER_ACCOUNT_REPOSITORY,
|
||||||
MpcKeygenCompletedHandler,
|
AccountSequenceGeneratorService,
|
||||||
// Tasks - 定时任务
|
UserValidatorService,
|
||||||
WalletRetryTask,
|
],
|
||||||
],
|
})
|
||||||
exports: [UserApplicationService, TokenService, TotpService],
|
export class DomainModule {}
|
||||||
})
|
|
||||||
export class ApplicationModule {}
|
// ============ Application Module ============
|
||||||
|
@Module({
|
||||||
// ============ API Module ============
|
imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()],
|
||||||
@Module({
|
providers: [
|
||||||
imports: [ApplicationModule],
|
UserApplicationService,
|
||||||
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController],
|
TokenService,
|
||||||
})
|
TotpService,
|
||||||
export class ApiModule {}
|
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
|
||||||
|
BlockchainWalletHandler,
|
||||||
// ============ App Module ============
|
MpcKeygenCompletedHandler,
|
||||||
@Module({
|
// Tasks - 定时任务
|
||||||
imports: [
|
WalletRetryTask,
|
||||||
ConfigModule.forRoot({
|
],
|
||||||
isGlobal: true,
|
exports: [UserApplicationService, TokenService, TotpService],
|
||||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
|
})
|
||||||
}),
|
export class ApplicationModule {}
|
||||||
JwtModule.registerAsync({
|
|
||||||
global: true,
|
// ============ API Module ============
|
||||||
inject: [ConfigService],
|
@Module({
|
||||||
useFactory: (configService: ConfigService) => ({
|
imports: [ApplicationModule],
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
controllers: [
|
||||||
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
HealthController,
|
||||||
}),
|
UserAccountController,
|
||||||
}),
|
ReferralsController,
|
||||||
InfrastructureModule,
|
AuthController,
|
||||||
DomainModule,
|
TotpController,
|
||||||
ApplicationModule,
|
],
|
||||||
ApiModule,
|
})
|
||||||
],
|
export class ApiModule {}
|
||||||
providers: [
|
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
// ============ App Module ============
|
||||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
@Module({
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
imports: [
|
||||||
],
|
ConfigModule.forRoot({
|
||||||
})
|
isGlobal: true,
|
||||||
export class AppModule {}
|
load: [
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
jwtConfig,
|
||||||
|
redisConfig,
|
||||||
|
kafkaConfig,
|
||||||
|
smsConfig,
|
||||||
|
walletConfig,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
global: true,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
InfrastructureModule,
|
||||||
|
DomainModule,
|
||||||
|
ApplicationModule,
|
||||||
|
ApiModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,45 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UserApplicationService } from './services/user-application.service';
|
import { UserApplicationService } from './services/user-application.service';
|
||||||
import { TokenService } from './services/token.service';
|
import { TokenService } from './services/token.service';
|
||||||
import { TotpService } from './services/totp.service';
|
import { TotpService } from './services/totp.service';
|
||||||
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
|
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
|
||||||
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
||||||
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
|
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
|
||||||
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
|
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
|
||||||
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
|
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
|
||||||
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
|
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
|
||||||
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
|
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
|
||||||
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
|
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
|
||||||
import { DomainModule } from '@/domain/domain.module';
|
import { DomainModule } from '@/domain/domain.module';
|
||||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DomainModule, InfrastructureModule],
|
imports: [DomainModule, InfrastructureModule],
|
||||||
providers: [
|
providers: [
|
||||||
UserApplicationService,
|
UserApplicationService,
|
||||||
TokenService,
|
TokenService,
|
||||||
TotpService,
|
TotpService,
|
||||||
AutoCreateAccountHandler,
|
AutoCreateAccountHandler,
|
||||||
RecoverByMnemonicHandler,
|
RecoverByMnemonicHandler,
|
||||||
RecoverByPhoneHandler,
|
RecoverByPhoneHandler,
|
||||||
BindPhoneHandler,
|
BindPhoneHandler,
|
||||||
GetMyProfileHandler,
|
GetMyProfileHandler,
|
||||||
GetMyDevicesHandler,
|
GetMyDevicesHandler,
|
||||||
// MPC Event Handlers
|
// MPC Event Handlers
|
||||||
MpcKeygenCompletedHandler,
|
MpcKeygenCompletedHandler,
|
||||||
// Blockchain Event Handlers
|
// Blockchain Event Handlers
|
||||||
BlockchainWalletHandler,
|
BlockchainWalletHandler,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
UserApplicationService,
|
UserApplicationService,
|
||||||
TokenService,
|
TokenService,
|
||||||
TotpService,
|
TotpService,
|
||||||
AutoCreateAccountHandler,
|
AutoCreateAccountHandler,
|
||||||
RecoverByMnemonicHandler,
|
RecoverByMnemonicHandler,
|
||||||
RecoverByPhoneHandler,
|
RecoverByPhoneHandler,
|
||||||
BindPhoneHandler,
|
BindPhoneHandler,
|
||||||
GetMyProfileHandler,
|
GetMyProfileHandler,
|
||||||
GetMyDevicesHandler,
|
GetMyDevicesHandler,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { DeviceNameInput } from '../index';
|
import { DeviceNameInput } from '../index';
|
||||||
|
|
||||||
export class AutoCreateAccountCommand {
|
export class AutoCreateAccountCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
public readonly deviceName?: DeviceNameInput,
|
public readonly deviceName?: DeviceNameInput,
|
||||||
public readonly inviterReferralCode?: string,
|
public readonly inviterReferralCode?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,113 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
UserAccountRepository,
|
||||||
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { TokenService } from '@/application/services/token.service';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import {
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
AccountSequenceGeneratorService,
|
||||||
import { AutoCreateAccountResult } from '../index';
|
UserValidatorService,
|
||||||
import { generateIdentity } from '@/shared/utils';
|
} from '@/domain/services';
|
||||||
|
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
||||||
@Injectable()
|
import { TokenService } from '@/application/services/token.service';
|
||||||
export class AutoCreateAccountHandler {
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
import { AutoCreateAccountResult } from '../index';
|
||||||
constructor(
|
import { generateIdentity } from '@/shared/utils';
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
|
||||||
private readonly userRepository: UserAccountRepository,
|
@Injectable()
|
||||||
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
export class AutoCreateAccountHandler {
|
||||||
private readonly validatorService: UserValidatorService,
|
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
private readonly eventPublisher: EventPublisherService,
|
constructor(
|
||||||
) {}
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
|
private readonly userRepository: UserAccountRepository,
|
||||||
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
||||||
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
private readonly validatorService: UserValidatorService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
// 1. 验证设备ID
|
private readonly eventPublisher: EventPublisherService,
|
||||||
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
|
) {}
|
||||||
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
|
|
||||||
|
async execute(
|
||||||
// 2. 验证邀请码
|
command: AutoCreateAccountCommand,
|
||||||
let inviterSequence: AccountSequence | null = null;
|
): Promise<AutoCreateAccountResult> {
|
||||||
if (command.inviterReferralCode) {
|
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
||||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
|
||||||
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
|
// 1. 验证设备ID
|
||||||
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
|
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
|
||||||
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
command.deviceId,
|
||||||
inviterSequence = inviter!.accountSequence;
|
);
|
||||||
}
|
if (!deviceCheck.isValid)
|
||||||
|
throw new ApplicationError(deviceCheck.errorMessage!);
|
||||||
// 3. 生成用户序列号
|
|
||||||
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
|
// 2. 验证邀请码
|
||||||
|
let inviterSequence: AccountSequence | null = null;
|
||||||
// 4. 生成用户名和头像
|
if (command.inviterReferralCode) {
|
||||||
const identity = generateIdentity(accountSequence.value);
|
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||||
|
const referralValidation =
|
||||||
// 5. 构建设备名称,保存完整的设备信息 JSON
|
await this.validatorService.validateReferralCode(referralCode);
|
||||||
let deviceNameStr = '未命名设备';
|
if (!referralValidation.isValid)
|
||||||
if (command.deviceName) {
|
throw new ApplicationError(referralValidation.errorMessage!);
|
||||||
const parts: string[] = [];
|
const inviter =
|
||||||
if (command.deviceName.model) parts.push(command.deviceName.model);
|
await this.userRepository.findByReferralCode(referralCode);
|
||||||
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
inviterSequence = inviter!.accountSequence;
|
||||||
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
|
}
|
||||||
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
|
||||||
}
|
// 3. 生成用户序列号
|
||||||
|
const accountSequence =
|
||||||
// 6. 创建账户 - 传递完整的 deviceName JSON
|
await this.sequenceGenerator.generateNextUserSequence();
|
||||||
const account = UserAccount.createAutomatic({
|
|
||||||
accountSequence,
|
// 4. 生成用户名和头像
|
||||||
initialDeviceId: command.deviceId,
|
const identity = generateIdentity(accountSequence.value);
|
||||||
deviceName: deviceNameStr,
|
|
||||||
deviceInfo: command.deviceName, // 100% 保持原样存储
|
// 5. 构建设备名称,保存完整的设备信息 JSON
|
||||||
inviterSequence,
|
let deviceNameStr = '未命名设备';
|
||||||
nickname: identity.username,
|
if (command.deviceName) {
|
||||||
avatarSvg: identity.avatarSvg,
|
const parts: string[] = [];
|
||||||
});
|
if (command.deviceName.model) parts.push(command.deviceName.model);
|
||||||
|
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
||||||
// 7. 保存账户
|
if (command.deviceName.osVersion)
|
||||||
await this.userRepository.save(account);
|
parts.push(command.deviceName.osVersion);
|
||||||
|
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
||||||
// 8. 生成 Token
|
}
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
|
||||||
userId: account.userId.toString(),
|
// 6. 创建账户 - 传递完整的 deviceName JSON
|
||||||
accountSequence: account.accountSequence.value,
|
const account = UserAccount.createAutomatic({
|
||||||
deviceId: command.deviceId,
|
accountSequence,
|
||||||
});
|
initialDeviceId: command.deviceId,
|
||||||
|
deviceName: deviceNameStr,
|
||||||
// 9. 发布领域事件
|
deviceInfo: command.deviceName, // 100% 保持原样存储
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
inviterSequence,
|
||||||
account.clearDomainEvents();
|
nickname: identity.username,
|
||||||
|
avatarSvg: identity.avatarSvg,
|
||||||
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`);
|
});
|
||||||
|
|
||||||
return {
|
// 7. 保存账户
|
||||||
userSerialNum: account.accountSequence.value,
|
await this.userRepository.save(account);
|
||||||
referralCode: account.referralCode.value,
|
|
||||||
username: account.nickname,
|
// 8. 生成 Token
|
||||||
avatarSvg: account.avatarUrl || identity.avatarSvg,
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
accessToken: tokens.accessToken,
|
userId: account.userId.toString(),
|
||||||
refreshToken: tokens.refreshToken,
|
accountSequence: account.accountSequence.value,
|
||||||
};
|
deviceId: command.deviceId,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
// 9. 发布领域事件
|
||||||
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
|
account.clearDomainEvents();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Account created: sequence=${accountSequence.value}, username=${identity.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userSerialNum: account.accountSequence.value,
|
||||||
|
referralCode: account.referralCode.value,
|
||||||
|
username: account.nickname,
|
||||||
|
avatarSvg: account.avatarUrl || identity.avatarSvg,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export class BindPhoneCommand {
|
export class BindPhoneCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,47 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { BindPhoneCommand } from './bind-phone.command';
|
import { BindPhoneCommand } from './bind-phone.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { UserValidatorService } from '@/domain/services';
|
UserAccountRepository,
|
||||||
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { UserValidatorService } from '@/domain/services';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
||||||
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
@Injectable()
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
export class BindPhoneHandler {
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
constructor(
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Injectable()
|
||||||
private readonly userRepository: UserAccountRepository,
|
export class BindPhoneHandler {
|
||||||
private readonly validatorService: UserValidatorService,
|
constructor(
|
||||||
private readonly redisService: RedisService,
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly userRepository: UserAccountRepository,
|
||||||
) {}
|
private readonly validatorService: UserValidatorService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
async execute(command: BindPhoneCommand): Promise<void> {
|
private readonly eventPublisher: EventPublisherService,
|
||||||
const account = await this.userRepository.findById(UserId.create(command.userId));
|
) {}
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
|
||||||
|
async execute(command: BindPhoneCommand): Promise<void> {
|
||||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
const account = await this.userRepository.findById(
|
||||||
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`);
|
UserId.create(command.userId),
|
||||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
);
|
||||||
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
|
|
||||||
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!);
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||||
|
const cachedCode = await this.redisService.get(
|
||||||
account.bindPhoneNumber(phoneNumber);
|
`sms:bind:${phoneNumber.value}`,
|
||||||
await this.userRepository.save(account);
|
);
|
||||||
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
|
if (cachedCode !== command.smsCode)
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
throw new ApplicationError('验证码错误或已过期');
|
||||||
account.clearDomainEvents();
|
|
||||||
}
|
const validation =
|
||||||
}
|
await this.validatorService.validatePhoneNumber(phoneNumber);
|
||||||
|
if (!validation.isValid)
|
||||||
|
throw new ApplicationError(validation.errorMessage!);
|
||||||
|
|
||||||
|
account.bindPhoneNumber(phoneNumber);
|
||||||
|
await this.userRepository.save(account);
|
||||||
|
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
|
||||||
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
|
account.clearDomainEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,312 +1,313 @@
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
// 设备信息输入 - 100% 保持前端传递的原样存储
|
// 设备信息输入 - 100% 保持前端传递的原样存储
|
||||||
export interface DeviceNameInput {
|
export interface DeviceNameInput {
|
||||||
model?: string; // iPhone 15 Pro, Pixel 8
|
model?: string; // iPhone 15 Pro, Pixel 8
|
||||||
platform?: string; // ios, android, web
|
platform?: string; // ios, android, web
|
||||||
osVersion?: string; // iOS 17.2, Android 14
|
osVersion?: string; // iOS 17.2, Android 14
|
||||||
[key: string]: unknown; // 允许任意其他字段
|
[key: string]: unknown; // 允许任意其他字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Commands ============
|
// ============ Commands ============
|
||||||
export class AutoCreateAccountCommand {
|
export class AutoCreateAccountCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
public readonly deviceName?: DeviceNameInput,
|
public readonly deviceName?: DeviceNameInput,
|
||||||
public readonly inviterReferralCode?: string,
|
public readonly inviterReferralCode?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RecoverByMnemonicCommand {
|
export class RecoverByMnemonicCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly mnemonic: string,
|
public readonly mnemonic: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RecoverByPhoneCommand {
|
export class RecoverByPhoneCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AutoLoginCommand {
|
export class AutoLoginCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly refreshToken: string,
|
public readonly refreshToken: string,
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegisterCommand {
|
export class RegisterCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
public readonly inviterReferralCode?: string,
|
public readonly inviterReferralCode?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginCommand {
|
export class LoginCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BindPhoneNumberCommand {
|
export class BindPhoneNumberCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateProfileCommand {
|
export class UpdateProfileCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly nickname?: string,
|
public readonly nickname?: string,
|
||||||
public readonly avatarUrl?: string,
|
public readonly avatarUrl?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BindWalletAddressCommand {
|
export class BindWalletAddressCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly chainType: string,
|
public readonly chainType: string,
|
||||||
public readonly address: string,
|
public readonly address: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubmitKYCCommand {
|
export class SubmitKYCCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly realName: string,
|
public readonly realName: string,
|
||||||
public readonly idCardNumber: string,
|
public readonly idCardNumber: string,
|
||||||
public readonly idCardFrontUrl: string,
|
public readonly idCardFrontUrl: string,
|
||||||
public readonly idCardBackUrl: string,
|
public readonly idCardBackUrl: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReviewKYCCommand {
|
export class ReviewKYCCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly approved: boolean,
|
public readonly approved: boolean,
|
||||||
public readonly reason?: string,
|
public readonly reason?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveDeviceCommand {
|
export class RemoveDeviceCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly currentDeviceId: string,
|
public readonly currentDeviceId: string,
|
||||||
public readonly deviceIdToRemove: string,
|
public readonly deviceIdToRemove: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendSmsCodeCommand {
|
export class SendSmsCodeCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Queries ============
|
// ============ Queries ============
|
||||||
export class GetMyProfileQuery {
|
export class GetMyProfileQuery {
|
||||||
constructor(public readonly userId: string) {}
|
constructor(public readonly userId: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetMyDevicesQuery {
|
export class GetMyDevicesQuery {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly currentDeviceId: string,
|
public readonly currentDeviceId: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetUserByReferralCodeQuery {
|
export class GetUserByReferralCodeQuery {
|
||||||
constructor(public readonly referralCode: string) {}
|
constructor(public readonly referralCode: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ValidateReferralCodeQuery {
|
export class ValidateReferralCodeQuery {
|
||||||
constructor(public readonly referralCode: string) {}
|
constructor(public readonly referralCode: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetReferralStatsQuery {
|
export class GetReferralStatsQuery {
|
||||||
constructor(public readonly userId: string) {}
|
constructor(public readonly userId: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GenerateReferralLinkCommand {
|
export class GenerateReferralLinkCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
||||||
public readonly campaignId?: string, // 活动ID
|
public readonly campaignId?: string, // 活动ID
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetWalletStatusQuery {
|
export class GetWalletStatusQuery {
|
||||||
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkMnemonicBackedUpCommand {
|
export class MarkMnemonicBackedUpCommand {
|
||||||
constructor(public readonly userId: string) {}
|
constructor(public readonly userId: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VerifySmsCodeCommand {
|
export class VerifySmsCodeCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SetPasswordCommand {
|
export class SetPasswordCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly password: string,
|
public readonly password: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Results ============
|
// ============ Results ============
|
||||||
|
|
||||||
// 钱包状态
|
// 钱包状态
|
||||||
export type WalletStatus = 'generating' | 'ready' | 'failed';
|
export type WalletStatus = 'generating' | 'ready' | 'failed';
|
||||||
|
|
||||||
export interface WalletStatusResult {
|
export interface WalletStatusResult {
|
||||||
status: WalletStatus;
|
status: WalletStatus;
|
||||||
walletAddresses?: {
|
walletAddresses?: {
|
||||||
kava: string;
|
kava: string;
|
||||||
dst: string;
|
dst: string;
|
||||||
bsc: string;
|
bsc: string;
|
||||||
};
|
};
|
||||||
mnemonic?: string; // 助记词 (ready 状态时返回)
|
mnemonic?: string; // 助记词 (ready 状态时返回)
|
||||||
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
||||||
}
|
}
|
||||||
export interface AutoCreateAccountResult {
|
export interface AutoCreateAccountResult {
|
||||||
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
||||||
referralCode: string; // 推荐码
|
referralCode: string; // 推荐码
|
||||||
username: string; // 随机用户名
|
username: string; // 随机用户名
|
||||||
avatarSvg: string; // 随机SVG头像
|
avatarSvg: string; // 随机SVG头像
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecoverAccountResult {
|
export interface RecoverAccountResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoLoginResult {
|
export interface AutoLoginResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResult {
|
export interface RegisterResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileDTO {
|
export interface UserProfileDTO {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||||
kycStatus: string;
|
kycStatus: string;
|
||||||
kycInfo: { realName: string; idCardNumber: string } | null;
|
kycInfo: { realName: string; idCardNumber: string } | null;
|
||||||
status: string;
|
status: string;
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceDTO {
|
export interface DeviceDTO {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
addedAt: Date;
|
addedAt: Date;
|
||||||
lastActiveAt: Date;
|
lastActiveAt: Date;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserBriefDTO {
|
export interface UserBriefDTO {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralCodeValidationResult {
|
export interface ReferralCodeValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
referralCode?: string;
|
referralCode?: string;
|
||||||
inviterInfo?: {
|
inviterInfo?: {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralLinkResult {
|
export interface ReferralLinkResult {
|
||||||
linkId: string;
|
linkId: string;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
shortUrl: string;
|
shortUrl: string;
|
||||||
fullUrl: string;
|
fullUrl: string;
|
||||||
channel: string | null;
|
channel: string | null;
|
||||||
campaignId: string | null;
|
campaignId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralStatsResult {
|
export interface ReferralStatsResult {
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
totalInvites: number; // 总邀请人数
|
totalInvites: number; // 总邀请人数
|
||||||
directInvites: number; // 直接邀请人数
|
directInvites: number; // 直接邀请人数
|
||||||
indirectInvites: number; // 间接邀请人数 (二级)
|
indirectInvites: number; // 间接邀请人数 (二级)
|
||||||
todayInvites: number; // 今日邀请
|
todayInvites: number; // 今日邀请
|
||||||
thisWeekInvites: number; // 本周邀请
|
thisWeekInvites: number; // 本周邀请
|
||||||
thisMonthInvites: number; // 本月邀请
|
thisMonthInvites: number; // 本月邀请
|
||||||
recentInvites: Array<{ // 最近邀请记录
|
recentInvites: Array<{
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
// 最近邀请记录
|
||||||
nickname: string;
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
avatarUrl: string | null;
|
nickname: string;
|
||||||
registeredAt: Date;
|
avatarUrl: string | null;
|
||||||
level: number; // 1=直接, 2=间接
|
registeredAt: Date;
|
||||||
}>;
|
level: number; // 1=直接, 2=间接
|
||||||
}
|
}>;
|
||||||
|
}
|
||||||
export interface MeResult {
|
|
||||||
userId: string;
|
export interface MeResult {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
userId: string;
|
||||||
phoneNumber: string | null;
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
nickname: string;
|
phoneNumber: string | null;
|
||||||
avatarUrl: string | null;
|
nickname: string;
|
||||||
referralCode: string;
|
avatarUrl: string | null;
|
||||||
referralLink: string; // 完整推荐链接
|
referralCode: string;
|
||||||
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
referralLink: string; // 完整推荐链接
|
||||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
||||||
kycStatus: string;
|
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||||
status: string;
|
kycStatus: string;
|
||||||
registeredAt: Date;
|
status: string;
|
||||||
}
|
registeredAt: Date;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
export class RecoverByMnemonicCommand {
|
export class RecoverByMnemonicCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly mnemonic: string,
|
public readonly mnemonic: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,106 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { AccountSequence } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
import { TokenService } from '@/application/services/token.service';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
import { AccountSequence } from '@/domain/value-objects';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { RecoverAccountResult } from '../index';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||||
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
@Injectable()
|
import { RecoverAccountResult } from '../index';
|
||||||
export class RecoverByMnemonicHandler {
|
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||||
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
|
|
||||||
|
@Injectable()
|
||||||
constructor(
|
export class RecoverByMnemonicHandler {
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
|
||||||
private readonly userRepository: UserAccountRepository,
|
|
||||||
private readonly tokenService: TokenService,
|
constructor(
|
||||||
private readonly eventPublisher: EventPublisherService,
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly blockchainClient: BlockchainClientService,
|
private readonly userRepository: UserAccountRepository,
|
||||||
) {}
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly eventPublisher: EventPublisherService,
|
||||||
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
private readonly blockchainClient: BlockchainClientService,
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
) {}
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
async execute(
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
command: RecoverByMnemonicCommand,
|
||||||
|
): Promise<RecoverAccountResult> {
|
||||||
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
this.logger.log(`Verifying mnemonic for account ${command.accountSequence}`);
|
const account =
|
||||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
accountSequence: command.accountSequence,
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
mnemonic: command.mnemonic,
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
});
|
|
||||||
|
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
||||||
if (!verifyResult.valid) {
|
this.logger.log(
|
||||||
this.logger.warn(`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`);
|
`Verifying mnemonic for account ${command.accountSequence}`,
|
||||||
throw new ApplicationError(verifyResult.message || '助记词错误');
|
);
|
||||||
}
|
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||||
|
accountSequence: command.accountSequence,
|
||||||
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`);
|
mnemonic: command.mnemonic,
|
||||||
|
});
|
||||||
// 如果头像为空,重新生成一个
|
|
||||||
let avatarUrl = account.avatarUrl;
|
if (!verifyResult.valid) {
|
||||||
this.logger.log(`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`);
|
this.logger.warn(
|
||||||
if (avatarUrl) {
|
`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`,
|
||||||
this.logger.log(`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`);
|
);
|
||||||
}
|
throw new ApplicationError(verifyResult.message || '助记词错误');
|
||||||
if (!avatarUrl) {
|
}
|
||||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
|
||||||
avatarUrl = generateRandomAvatarSvg();
|
this.logger.log(
|
||||||
account.updateProfile({ avatarUrl });
|
`Mnemonic verified successfully for account ${command.accountSequence}`,
|
||||||
}
|
);
|
||||||
|
|
||||||
account.addDevice(command.newDeviceId, command.deviceName);
|
// 如果头像为空,重新生成一个
|
||||||
account.recordLogin();
|
let avatarUrl = account.avatarUrl;
|
||||||
await this.userRepository.save(account);
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`,
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
);
|
||||||
userId: account.userId.toString(),
|
if (avatarUrl) {
|
||||||
accountSequence: account.accountSequence.value,
|
this.logger.log(
|
||||||
deviceId: command.newDeviceId,
|
`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`,
|
||||||
});
|
);
|
||||||
|
}
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
if (!avatarUrl) {
|
||||||
account.clearDomainEvents();
|
this.logger.log(
|
||||||
|
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||||
const result = {
|
);
|
||||||
userId: account.userId.toString(),
|
avatarUrl = generateRandomAvatarSvg();
|
||||||
accountSequence: account.accountSequence.value,
|
account.updateProfile({ avatarUrl });
|
||||||
nickname: account.nickname,
|
}
|
||||||
avatarUrl,
|
|
||||||
referralCode: account.referralCode.value,
|
account.addDevice(command.newDeviceId, command.deviceName);
|
||||||
accessToken: tokens.accessToken,
|
account.recordLogin();
|
||||||
refreshToken: tokens.refreshToken,
|
await this.userRepository.save(account);
|
||||||
};
|
|
||||||
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`);
|
userId: account.userId.toString(),
|
||||||
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`);
|
accountSequence: account.accountSequence.value,
|
||||||
|
deviceId: command.newDeviceId,
|
||||||
return result;
|
});
|
||||||
}
|
|
||||||
}
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
|
account.clearDomainEvents();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
userId: account.userId.toString(),
|
||||||
|
accountSequence: account.accountSequence.value,
|
||||||
|
nickname: account.nickname,
|
||||||
|
avatarUrl,
|
||||||
|
referralCode: account.referralCode.value,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export class RecoverByPhoneCommand {
|
export class RecoverByPhoneCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName?: string,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,80 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
import { TokenService } from '@/application/services/token.service';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { RecoverAccountResult } from '../index';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
@Injectable()
|
import { RecoverAccountResult } from '../index';
|
||||||
export class RecoverByPhoneHandler {
|
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||||
private readonly logger = new Logger(RecoverByPhoneHandler.name);
|
|
||||||
|
@Injectable()
|
||||||
constructor(
|
export class RecoverByPhoneHandler {
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
private readonly logger = new Logger(RecoverByPhoneHandler.name);
|
||||||
private readonly userRepository: UserAccountRepository,
|
|
||||||
private readonly tokenService: TokenService,
|
constructor(
|
||||||
private readonly redisService: RedisService,
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly userRepository: UserAccountRepository,
|
||||||
) {}
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
private readonly eventPublisher: EventPublisherService,
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
) {}
|
||||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
|
||||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
const account =
|
||||||
|
await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
|
if (!account.phoneNumber)
|
||||||
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
|
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
|
||||||
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||||
// 如果头像为空,重新生成一个
|
if (!account.phoneNumber.equals(phoneNumber))
|
||||||
let avatarUrl = account.avatarUrl;
|
throw new ApplicationError('手机号与账户不匹配');
|
||||||
if (!avatarUrl) {
|
|
||||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
const cachedCode = await this.redisService.get(
|
||||||
avatarUrl = generateRandomAvatarSvg();
|
`sms:recover:${phoneNumber.value}`,
|
||||||
account.updateProfile({ avatarUrl });
|
);
|
||||||
}
|
if (cachedCode !== command.smsCode)
|
||||||
|
throw new ApplicationError('验证码错误或已过期');
|
||||||
account.addDevice(command.newDeviceId, command.deviceName);
|
|
||||||
account.recordLogin();
|
// 如果头像为空,重新生成一个
|
||||||
await this.userRepository.save(account);
|
let avatarUrl = account.avatarUrl;
|
||||||
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
|
if (!avatarUrl) {
|
||||||
|
this.logger.log(
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||||
userId: account.userId.toString(),
|
);
|
||||||
accountSequence: account.accountSequence.value,
|
avatarUrl = generateRandomAvatarSvg();
|
||||||
deviceId: command.newDeviceId,
|
account.updateProfile({ avatarUrl });
|
||||||
});
|
}
|
||||||
|
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
account.addDevice(command.newDeviceId, command.deviceName);
|
||||||
account.clearDomainEvents();
|
account.recordLogin();
|
||||||
|
await this.userRepository.save(account);
|
||||||
return {
|
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
|
||||||
userId: account.userId.toString(),
|
|
||||||
accountSequence: account.accountSequence.value,
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
nickname: account.nickname,
|
userId: account.userId.toString(),
|
||||||
avatarUrl,
|
accountSequence: account.accountSequence.value,
|
||||||
referralCode: account.referralCode.value,
|
deviceId: command.newDeviceId,
|
||||||
accessToken: tokens.accessToken,
|
});
|
||||||
refreshToken: tokens.refreshToken,
|
|
||||||
};
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
}
|
account.clearDomainEvents();
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
userId: account.userId.toString(),
|
||||||
|
accountSequence: account.accountSequence.value,
|
||||||
|
nickname: account.nickname,
|
||||||
|
avatarUrl,
|
||||||
|
referralCode: account.referralCode.value,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { ChainType, UserId } from '@/domain/value-objects';
|
import { ChainType, UserId } from '@/domain/value-objects';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
|
|
@ -31,7 +34,7 @@ interface WalletCompletedStatusData {
|
||||||
userId: string;
|
userId: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
walletAddresses?: { chainType: string; address: string }[];
|
walletAddresses?: { chainType: string; address: string }[];
|
||||||
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,8 +52,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// Register event handler
|
// Register event handler
|
||||||
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this));
|
this.blockchainEventConsumer.onWalletAddressCreated(
|
||||||
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events');
|
this.handleWalletAddressCreated.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
'[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,21 +68,36 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
* - DST: dst1... (Cosmos bech32)
|
* - DST: dst1... (Cosmos bech32)
|
||||||
* - BSC: 0x... (EVM)
|
* - BSC: 0x... (EVM)
|
||||||
*/
|
*/
|
||||||
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
|
private async handleWalletAddressCreated(
|
||||||
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload;
|
payload: WalletAddressCreatedPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
publicKey,
|
||||||
|
addresses,
|
||||||
|
mnemonic,
|
||||||
|
encryptedMnemonic,
|
||||||
|
mnemonicHash,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated: userId=${userId}`);
|
this.logger.log(
|
||||||
|
`[HANDLE] Processing WalletAddressCreated: userId=${userId}`,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||||
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
||||||
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
|
this.logger.error(
|
||||||
|
'[ERROR] WalletAddressCreated event missing userId, skipping',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!addresses || addresses.length === 0) {
|
if (!addresses || addresses.length === 0) {
|
||||||
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping');
|
this.logger.error(
|
||||||
|
'[ERROR] WalletAddressCreated event missing addresses, skipping',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,23 +112,29 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
// 2. Create wallet addresses for each chain (with publicKey)
|
// 2. Create wallet addresses for each chain (with publicKey)
|
||||||
const wallets: WalletAddress[] = addresses.map((addr) => {
|
const wallets: WalletAddress[] = addresses.map((addr) => {
|
||||||
const chainType = this.parseChainType(addr.chainType);
|
const chainType = this.parseChainType(addr.chainType);
|
||||||
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`);
|
this.logger.log(
|
||||||
|
`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`,
|
||||||
|
);
|
||||||
return WalletAddress.create({
|
return WalletAddress.create({
|
||||||
userId: account.userId,
|
userId: account.userId,
|
||||||
chainType,
|
chainType,
|
||||||
address: addr.address,
|
address: addr.address,
|
||||||
publicKey, // 传入公钥,用于关联助记词
|
publicKey, // 传入公钥,用于关联助记词
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Save wallet addresses to user account
|
// 3. Save wallet addresses to user account
|
||||||
await this.userRepository.saveWallets(account.userId, wallets);
|
await this.userRepository.saveWallets(account.userId, wallets);
|
||||||
this.logger.log(`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
|
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
|
||||||
// Note: blockchain-service stores mnemonic with accountSequence association
|
// Note: blockchain-service stores mnemonic with accountSequence association
|
||||||
if (mnemonic) {
|
if (mnemonic) {
|
||||||
this.logger.log(`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`);
|
this.logger.log(
|
||||||
|
`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
||||||
|
|
@ -116,7 +144,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
userId,
|
userId,
|
||||||
publicKey,
|
publicKey,
|
||||||
walletAddresses: addresses,
|
walletAddresses: addresses,
|
||||||
mnemonic, // 首次返回明文助记词
|
mnemonic, // 首次返回明文助记词
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,9 +156,13 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'completed' for user: ${userId}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`);
|
this.logger.log(
|
||||||
|
`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log all addresses
|
// Log all addresses
|
||||||
|
|
@ -138,7 +170,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
|
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to process WalletAddressCreated: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to process WalletAddressCreated: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -158,9 +193,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
||||||
case 'BSC':
|
case 'BSC':
|
||||||
return ChainType.BSC;
|
return ChainType.BSC;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`);
|
this.logger.warn(
|
||||||
|
`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`,
|
||||||
|
);
|
||||||
return ChainType.BSC;
|
return ChainType.BSC;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,12 @@ import {
|
||||||
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
|
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
|
||||||
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
|
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
|
||||||
|
|
||||||
export type KeygenStatus = 'pending' | 'generating' | 'deriving' | 'completed' | 'failed';
|
export type KeygenStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'generating'
|
||||||
|
| 'deriving'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
export interface KeygenStatusData {
|
export interface KeygenStatusData {
|
||||||
status: KeygenStatus;
|
status: KeygenStatus;
|
||||||
|
|
@ -48,9 +53,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
|
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
|
||||||
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
|
this.mpcEventConsumer.onKeygenCompleted(
|
||||||
|
this.handleKeygenCompleted.bind(this),
|
||||||
|
);
|
||||||
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
||||||
this.logger.log('[INIT] Registered MPC event handlers (status updates only)');
|
this.logger.log(
|
||||||
|
'[INIT] Registered MPC event handlers (status updates only)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,9 +67,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
*
|
*
|
||||||
* Update Redis status to "generating"
|
* Update Redis status to "generating"
|
||||||
*/
|
*/
|
||||||
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> {
|
private async handleKeygenStarted(
|
||||||
|
payload: KeygenStartedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { userId, mpcSessionId } = payload;
|
const { userId, mpcSessionId } = payload;
|
||||||
this.logger.log(`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statusData: KeygenStatusData = {
|
const statusData: KeygenStatusData = {
|
||||||
|
|
@ -76,9 +89,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
KEYGEN_STATUS_TTL,
|
KEYGEN_STATUS_TTL,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'generating' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'generating' for user: ${userId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +112,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
* Uses atomic Redis update to ensure status only advances forward:
|
* Uses atomic Redis update to ensure status only advances forward:
|
||||||
* pending -> generating -> deriving -> completed
|
* pending -> generating -> deriving -> completed
|
||||||
*/
|
*/
|
||||||
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
|
private async handleKeygenCompleted(
|
||||||
|
payload: KeygenCompletedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { publicKey, extraPayload } = payload;
|
const { publicKey, extraPayload } = payload;
|
||||||
|
|
||||||
if (!extraPayload?.userId) {
|
if (!extraPayload?.userId) {
|
||||||
|
|
@ -103,11 +123,15 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, username } = extraPayload;
|
const { userId, username } = extraPayload;
|
||||||
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen completed: userId=${userId}, username=${username}`,
|
||||||
|
);
|
||||||
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
|
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
|
this.logger.log(
|
||||||
|
`[STATUS] Waiting for blockchain-service to derive addresses...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Update status to "deriving" - waiting for blockchain-service
|
// Update status to "deriving" - waiting for blockchain-service
|
||||||
// Uses atomic operation to ensure we don't overwrite higher-priority status
|
// Uses atomic operation to ensure we don't overwrite higher-priority status
|
||||||
|
|
@ -126,13 +150,22 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
|
this.logger.log(
|
||||||
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
|
`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`);
|
this.logger.log(
|
||||||
|
`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +178,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
* 1. Log error
|
* 1. Log error
|
||||||
* 2. Update Redis status to "failed"
|
* 2. Update Redis status to "failed"
|
||||||
*/
|
*/
|
||||||
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
|
private async handleSessionFailed(
|
||||||
|
payload: SessionFailedPayload,
|
||||||
|
): Promise<void> {
|
||||||
const { sessionType, errorMessage, extraPayload } = payload;
|
const { sessionType, errorMessage, extraPayload } = payload;
|
||||||
|
|
||||||
// Only handle keygen failures
|
// Only handle keygen failures
|
||||||
|
|
@ -154,7 +189,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = extraPayload?.userId || 'unknown';
|
const userId = extraPayload?.userId || 'unknown';
|
||||||
this.logger.error(`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`);
|
this.logger.error(
|
||||||
|
`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update Redis status to failed
|
// Update Redis status to failed
|
||||||
|
|
@ -171,9 +208,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
||||||
KEYGEN_STATUS_TTL,
|
KEYGEN_STATUS_TTL,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[STATUS] Keygen status updated to 'failed' for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[STATUS] Keygen status updated to 'failed' for user: ${userId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to update keygen failed status: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to update keygen failed status: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,32 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { GetMyDevicesQuery } from './get-my-devices.query';
|
import { GetMyDevicesQuery } from './get-my-devices.query';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { UserId } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { DeviceDTO } from '@/application/commands';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
|
import { UserId } from '@/domain/value-objects';
|
||||||
@Injectable()
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
export class GetMyDevicesHandler {
|
import { DeviceDTO } from '@/application/commands';
|
||||||
constructor(
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Injectable()
|
||||||
private readonly userRepository: UserAccountRepository,
|
export class GetMyDevicesHandler {
|
||||||
) {}
|
constructor(
|
||||||
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
private readonly userRepository: UserAccountRepository,
|
||||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
) {}
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
|
||||||
|
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
||||||
return account.getAllDevices().map((device) => ({
|
const account = await this.userRepository.findById(
|
||||||
deviceId: device.deviceId,
|
UserId.create(query.userId),
|
||||||
deviceName: device.deviceName,
|
);
|
||||||
addedAt: device.addedAt,
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
lastActiveAt: device.lastActiveAt,
|
|
||||||
isCurrent: device.deviceId === query.currentDeviceId,
|
return account.getAllDevices().map((device) => ({
|
||||||
}));
|
deviceId: device.deviceId,
|
||||||
}
|
deviceName: device.deviceName,
|
||||||
}
|
addedAt: device.addedAt,
|
||||||
|
lastActiveAt: device.lastActiveAt,
|
||||||
|
isCurrent: device.deviceId === query.currentDeviceId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class GetMyDevicesQuery {
|
export class GetMyDevicesQuery {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly currentDeviceId: string,
|
public readonly currentDeviceId: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,51 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { GetMyProfileQuery } from './get-my-profile.query';
|
import { GetMyProfileQuery } from './get-my-profile.query';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
UserAccountRepository,
|
||||||
import { UserId } from '@/domain/value-objects';
|
USER_ACCOUNT_REPOSITORY,
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { UserProfileDTO } from '@/application/commands';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
|
import { UserId } from '@/domain/value-objects';
|
||||||
@Injectable()
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
export class GetMyProfileHandler {
|
import { UserProfileDTO } from '@/application/commands';
|
||||||
constructor(
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Injectable()
|
||||||
private readonly userRepository: UserAccountRepository,
|
export class GetMyProfileHandler {
|
||||||
) {}
|
constructor(
|
||||||
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
private readonly userRepository: UserAccountRepository,
|
||||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
) {}
|
||||||
if (!account) throw new ApplicationError('用户不存在');
|
|
||||||
return this.toDTO(account);
|
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
||||||
}
|
const account = await this.userRepository.findById(
|
||||||
|
UserId.create(query.userId),
|
||||||
private toDTO(account: UserAccount): UserProfileDTO {
|
);
|
||||||
return {
|
if (!account) throw new ApplicationError('用户不存在');
|
||||||
userId: account.userId.toString(),
|
return this.toDTO(account);
|
||||||
accountSequence: account.accountSequence.value,
|
}
|
||||||
phoneNumber: account.phoneNumber?.masked() || null,
|
|
||||||
nickname: account.nickname,
|
private toDTO(account: UserAccount): UserProfileDTO {
|
||||||
avatarUrl: account.avatarUrl,
|
return {
|
||||||
referralCode: account.referralCode.value,
|
userId: account.userId.toString(),
|
||||||
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
|
accountSequence: account.accountSequence.value,
|
||||||
chainType: wa.chainType,
|
phoneNumber: account.phoneNumber?.masked() || null,
|
||||||
address: wa.address,
|
nickname: account.nickname,
|
||||||
})),
|
avatarUrl: account.avatarUrl,
|
||||||
kycStatus: account.kycStatus,
|
referralCode: account.referralCode.value,
|
||||||
kycInfo: account.kycInfo
|
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
|
||||||
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
|
chainType: wa.chainType,
|
||||||
: null,
|
address: wa.address,
|
||||||
status: account.status,
|
})),
|
||||||
registeredAt: account.registeredAt,
|
kycStatus: account.kycStatus,
|
||||||
lastLoginAt: account.lastLoginAt,
|
kycInfo: account.kycInfo
|
||||||
};
|
? {
|
||||||
}
|
realName: account.kycInfo.realName,
|
||||||
}
|
idCardNumber: account.kycInfo.maskedIdCardNumber(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status: account.status,
|
||||||
|
registeredAt: account.registeredAt,
|
||||||
|
lastLoginAt: account.lastLoginAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export class GetMyProfileQuery {
|
export class GetMyProfileQuery {
|
||||||
constructor(public readonly userId: string) {}
|
constructor(public readonly userId: string) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,103 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateTokenPair(payload: {
|
async generateTokenPair(payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}): Promise<{ accessToken: string; refreshToken: string }> {
|
}): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
const accessToken = this.jwtService.sign(
|
const accessToken = this.jwtService.sign(
|
||||||
{ ...payload, type: 'access' },
|
{ ...payload, type: 'access' },
|
||||||
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
{
|
||||||
);
|
expiresIn: this.configService.get<string>(
|
||||||
|
'JWT_ACCESS_EXPIRES_IN',
|
||||||
const refreshToken = this.jwtService.sign(
|
'2h',
|
||||||
{ ...payload, type: 'refresh' },
|
),
|
||||||
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') },
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save refresh token hash
|
const refreshToken = this.jwtService.sign(
|
||||||
const tokenHash = this.hashToken(refreshToken);
|
{ ...payload, type: 'refresh' },
|
||||||
await this.prisma.deviceToken.create({
|
{
|
||||||
data: {
|
expiresIn: this.configService.get<string>(
|
||||||
userId: BigInt(payload.userId),
|
'JWT_REFRESH_EXPIRES_IN',
|
||||||
deviceId: payload.deviceId,
|
'30d',
|
||||||
refreshTokenHash: tokenHash,
|
),
|
||||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
},
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
// Save refresh token hash
|
||||||
return { accessToken, refreshToken };
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
}
|
await this.prisma.deviceToken.create({
|
||||||
|
data: {
|
||||||
async verifyRefreshToken(token: string): Promise<{
|
userId: BigInt(payload.userId),
|
||||||
userId: string;
|
deviceId: payload.deviceId,
|
||||||
accountSequence: string;
|
refreshTokenHash: tokenHash,
|
||||||
deviceId: string;
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
}> {
|
},
|
||||||
try {
|
});
|
||||||
const payload = this.jwtService.verify<TokenPayload>(token);
|
|
||||||
if (payload.type !== 'refresh') {
|
return { accessToken, refreshToken };
|
||||||
throw new ApplicationError('无效的RefreshToken');
|
}
|
||||||
}
|
|
||||||
|
async verifyRefreshToken(token: string): Promise<{
|
||||||
const tokenHash = this.hashToken(token);
|
userId: string;
|
||||||
const storedToken = await this.prisma.deviceToken.findUnique({
|
accountSequence: string;
|
||||||
where: { refreshTokenHash: tokenHash },
|
deviceId: string;
|
||||||
});
|
}> {
|
||||||
|
try {
|
||||||
if (!storedToken || storedToken.revokedAt) {
|
const payload = this.jwtService.verify<TokenPayload>(token);
|
||||||
throw new ApplicationError('RefreshToken已失效');
|
if (payload.type !== 'refresh') {
|
||||||
}
|
throw new ApplicationError('无效的RefreshToken');
|
||||||
|
}
|
||||||
return {
|
|
||||||
userId: payload.userId,
|
const tokenHash = this.hashToken(token);
|
||||||
accountSequence: payload.accountSequence,
|
const storedToken = await this.prisma.deviceToken.findUnique({
|
||||||
deviceId: payload.deviceId,
|
where: { refreshTokenHash: tokenHash },
|
||||||
};
|
});
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApplicationError) throw error;
|
if (!storedToken || storedToken.revokedAt) {
|
||||||
throw new ApplicationError('RefreshToken已过期或无效');
|
throw new ApplicationError('RefreshToken已失效');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
|
userId: payload.userId,
|
||||||
await this.prisma.deviceToken.updateMany({
|
accountSequence: payload.accountSequence,
|
||||||
where: { userId: BigInt(userId), deviceId, revokedAt: null },
|
deviceId: payload.deviceId,
|
||||||
data: { revokedAt: new Date() },
|
};
|
||||||
});
|
} catch (error) {
|
||||||
}
|
if (error instanceof ApplicationError) throw error;
|
||||||
|
throw new ApplicationError('RefreshToken已过期或无效');
|
||||||
private hashToken(token: string): string {
|
}
|
||||||
return createHash('sha256').update(token).digest('hex');
|
}
|
||||||
}
|
|
||||||
}
|
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
|
||||||
|
await this.prisma.deviceToken.updateMany({
|
||||||
|
where: { userId: BigInt(userId), deviceId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ export class TotpService {
|
||||||
private readonly logger = new Logger(TotpService.name);
|
private readonly logger = new Logger(TotpService.name);
|
||||||
|
|
||||||
// TOTP 配置
|
// TOTP 配置
|
||||||
private readonly TOTP_DIGITS = 6; // 验证码位数
|
private readonly TOTP_DIGITS = 6; // 验证码位数
|
||||||
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
||||||
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
||||||
private readonly ISSUER = 'RWADurian'; // 应用名称
|
private readonly ISSUER = 'RWADurian'; // 应用名称
|
||||||
|
|
||||||
// AES 加密密钥 (生产环境应从环境变量获取)
|
// AES 加密密钥 (生产环境应从环境变量获取)
|
||||||
private readonly ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
private readonly ENCRYPTION_KEY =
|
||||||
|
process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { UserId } from '@/domain/value-objects';
|
import { UserId } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
|
@ -64,7 +67,9 @@ export class WalletRetryTask {
|
||||||
async handleWalletRetry() {
|
async handleWalletRetry() {
|
||||||
// 防止并发执行
|
// 防止并发执行
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
this.logger.warn('[TASK] Previous task still running, skipping this execution');
|
this.logger.warn(
|
||||||
|
'[TASK] Previous task still running, skipping this execution',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +78,12 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 扫描所有 keygen:status:* keys
|
// 1. 扫描所有 keygen:status:* keys
|
||||||
const statusKeys = await this.redisService.keys(`${KEYGEN_STATUS_PREFIX}*`);
|
const statusKeys = await this.redisService.keys(
|
||||||
this.logger.log(`[TASK] Found ${statusKeys.length} wallet generation records`);
|
`${KEYGEN_STATUS_PREFIX}*`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[TASK] Found ${statusKeys.length} wallet generation records`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const key of statusKeys) {
|
for (const key of statusKeys) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -123,7 +132,9 @@ export class WalletRetryTask {
|
||||||
// 检查重试限制
|
// 检查重试限制
|
||||||
const canRetry = await this.checkRetryLimit(userId);
|
const canRetry = await this.checkRetryLimit(userId);
|
||||||
if (!canRetry) {
|
if (!canRetry) {
|
||||||
this.logger.warn(`[TASK] User ${userId} exceeded retry time limit (10 minutes)`);
|
this.logger.warn(
|
||||||
|
`[TASK] User ${userId} exceeded retry time limit (10 minutes)`,
|
||||||
|
);
|
||||||
// 更新状态为最终失败
|
// 更新状态为最终失败
|
||||||
await this.markAsFinalFailure(userId);
|
await this.markAsFinalFailure(userId);
|
||||||
return;
|
return;
|
||||||
|
|
@ -144,19 +155,25 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
// 情况1:状态为 failed
|
// 情况1:状态为 failed
|
||||||
if (currentStatus === 'failed') {
|
if (currentStatus === 'failed') {
|
||||||
this.logger.log(`[TASK] User ${status.userId} status is 'failed', will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} status is 'failed', will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况2:状态为 generating 但超过 60 秒
|
// 情况2:状态为 generating 但超过 60 秒
|
||||||
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
|
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||||
this.logger.log(`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况3:状态为 deriving 但超过 60 秒
|
// 情况3:状态为 deriving 但超过 60 秒
|
||||||
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
|
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||||
this.logger.log(`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
this.logger.log(
|
||||||
|
`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,7 +206,9 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
// 如果超过 10 分钟,不再重试
|
// 如果超过 10 分钟,不再重试
|
||||||
if (elapsed > MAX_RETRY_DURATION_MS) {
|
if (elapsed > MAX_RETRY_DURATION_MS) {
|
||||||
this.logger.warn(`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`);
|
this.logger.warn(
|
||||||
|
`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +242,9 @@ export class WalletRetryTask {
|
||||||
|
|
||||||
await this.eventPublisher.publish(event);
|
await this.eventPublisher.publish(event);
|
||||||
|
|
||||||
this.logger.log(`[TASK] Wallet generation retry triggered for user: ${userId}`);
|
this.logger.log(
|
||||||
|
`[TASK] Wallet generation retry triggered for user: ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 4. 更新 Redis 状态为 pending(等待重新生成)
|
// 4. 更新 Redis 状态为 pending(等待重新生成)
|
||||||
const statusData: KeygenStatusData = {
|
const statusData: KeygenStatusData = {
|
||||||
|
|
@ -238,7 +259,10 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`, error);
|
this.logger.error(
|
||||||
|
`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,7 +298,9 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`);
|
this.logger.log(
|
||||||
|
`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -294,6 +320,8 @@ export class WalletRetryTask {
|
||||||
60 * 60 * 24, // 24 小时
|
60 * 60 * 24, // 24 小时
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.error(`[TASK] Marked user ${userId} as final failure after retry timeout`);
|
this.logger.error(
|
||||||
|
`[TASK] Marked user ${userId} as final failure after retry timeout`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const appConfig = () => ({
|
export const appConfig = () => ({
|
||||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||||
env: process.env.APP_ENV || 'development',
|
env: process.env.APP_ENV || 'development',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export const databaseConfig = () => ({
|
export const databaseConfig = () => ({
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,44 @@
|
||||||
export const appConfig = () => ({
|
export const appConfig = () => ({
|
||||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||||
env: process.env.APP_ENV || 'development',
|
env: process.env.APP_ENV || 'development',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const databaseConfig = () => ({
|
export const databaseConfig = () => ({
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const jwtConfig = () => ({
|
export const jwtConfig = () => ({
|
||||||
secret: process.env.JWT_SECRET || 'default-secret',
|
secret: process.env.JWT_SECRET || 'default-secret',
|
||||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const redisConfig = () => ({
|
export const redisConfig = () => ({
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD || undefined,
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const kafkaConfig = () => ({
|
export const kafkaConfig = () => ({
|
||||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const smsConfig = () => ({
|
export const smsConfig = () => ({
|
||||||
// 阿里云 SMS 配置
|
// 阿里云 SMS 配置
|
||||||
aliyun: {
|
aliyun: {
|
||||||
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
||||||
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
||||||
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
|
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
|
||||||
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
|
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
|
||||||
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
||||||
},
|
},
|
||||||
// 是否启用真实发送(开发环境可关闭)
|
// 是否启用真实发送(开发环境可关闭)
|
||||||
enabled: process.env.SMS_ENABLED === 'true',
|
enabled: process.env.SMS_ENABLED === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const walletConfig = () => ({
|
export const walletConfig = () => ({
|
||||||
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
|
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const jwtConfig = () => ({
|
export const jwtConfig = () => ({
|
||||||
secret: process.env.JWT_SECRET || 'default-secret',
|
secret: process.env.JWT_SECRET || 'default-secret',
|
||||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const kafkaConfig = () => ({
|
export const kafkaConfig = () => ({
|
||||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const redisConfig = () => ({
|
export const redisConfig = () => ({
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD || undefined,
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from './user-account.aggregate';
|
export * from './user-account.aggregate';
|
||||||
|
|
|
||||||
|
|
@ -1,356 +1,524 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import {
|
import {
|
||||||
UserId, AccountSequence, PhoneNumber, ReferralCode,
|
UserId,
|
||||||
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
|
AccountSequence,
|
||||||
} from '@/domain/value-objects';
|
PhoneNumber,
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
ReferralCode,
|
||||||
import {
|
DeviceInfo,
|
||||||
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
|
ChainType,
|
||||||
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
|
KYCInfo,
|
||||||
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
|
KYCStatus,
|
||||||
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
|
AccountStatus,
|
||||||
UserAccountFrozenEvent, UserAccountDeactivatedEvent,
|
} from '@/domain/value-objects';
|
||||||
} from '@/domain/events';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
|
import {
|
||||||
export class UserAccount {
|
DomainEvent,
|
||||||
private readonly _userId: UserId;
|
UserAccountAutoCreatedEvent,
|
||||||
private readonly _accountSequence: AccountSequence;
|
UserAccountCreatedEvent,
|
||||||
private _devices: Map<string, DeviceInfo>;
|
DeviceAddedEvent,
|
||||||
private _phoneNumber: PhoneNumber | null;
|
DeviceRemovedEvent,
|
||||||
private _nickname: string;
|
PhoneNumberBoundEvent,
|
||||||
private _avatarUrl: string | null;
|
WalletAddressBoundEvent,
|
||||||
private readonly _inviterSequence: AccountSequence | null;
|
MultipleWalletAddressesBoundEvent,
|
||||||
private readonly _referralCode: ReferralCode;
|
KYCSubmittedEvent,
|
||||||
private _walletAddresses: Map<ChainType, WalletAddress>;
|
KYCVerifiedEvent,
|
||||||
private _kycInfo: KYCInfo | null;
|
KYCRejectedEvent,
|
||||||
private _kycStatus: KYCStatus;
|
UserAccountFrozenEvent,
|
||||||
private _status: AccountStatus;
|
UserAccountDeactivatedEvent,
|
||||||
private readonly _registeredAt: Date;
|
} from '@/domain/events';
|
||||||
private _lastLoginAt: Date | null;
|
|
||||||
private _updatedAt: Date;
|
export class UserAccount {
|
||||||
private _domainEvents: DomainEvent[] = [];
|
private readonly _userId: UserId;
|
||||||
|
private readonly _accountSequence: AccountSequence;
|
||||||
// Getters
|
private _devices: Map<string, DeviceInfo>;
|
||||||
get userId(): UserId { return this._userId; }
|
private _phoneNumber: PhoneNumber | null;
|
||||||
get accountSequence(): AccountSequence { return this._accountSequence; }
|
private _nickname: string;
|
||||||
get phoneNumber(): PhoneNumber | null { return this._phoneNumber; }
|
private _avatarUrl: string | null;
|
||||||
get nickname(): string { return this._nickname; }
|
private readonly _inviterSequence: AccountSequence | null;
|
||||||
get avatarUrl(): string | null { return this._avatarUrl; }
|
private readonly _referralCode: ReferralCode;
|
||||||
get inviterSequence(): AccountSequence | null { return this._inviterSequence; }
|
private _walletAddresses: Map<ChainType, WalletAddress>;
|
||||||
get referralCode(): ReferralCode { return this._referralCode; }
|
private _kycInfo: KYCInfo | null;
|
||||||
get kycInfo(): KYCInfo | null { return this._kycInfo; }
|
private _kycStatus: KYCStatus;
|
||||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
private _status: AccountStatus;
|
||||||
get status(): AccountStatus { return this._status; }
|
private readonly _registeredAt: Date;
|
||||||
get registeredAt(): Date { return this._registeredAt; }
|
private _lastLoginAt: Date | null;
|
||||||
get lastLoginAt(): Date | null { return this._lastLoginAt; }
|
private _updatedAt: Date;
|
||||||
get updatedAt(): Date { return this._updatedAt; }
|
private _domainEvents: DomainEvent[] = [];
|
||||||
get isActive(): boolean { return this._status === AccountStatus.ACTIVE; }
|
|
||||||
get isKYCVerified(): boolean { return this._kycStatus === KYCStatus.VERIFIED; }
|
// Getters
|
||||||
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
get userId(): UserId {
|
||||||
|
return this._userId;
|
||||||
private constructor(
|
}
|
||||||
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>,
|
get accountSequence(): AccountSequence {
|
||||||
phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null,
|
return this._accountSequence;
|
||||||
inviterSequence: AccountSequence | null, referralCode: ReferralCode,
|
}
|
||||||
walletAddresses: Map<ChainType, WalletAddress>, kycInfo: KYCInfo | null,
|
get phoneNumber(): PhoneNumber | null {
|
||||||
kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date,
|
return this._phoneNumber;
|
||||||
lastLoginAt: Date | null, updatedAt: Date,
|
}
|
||||||
) {
|
get nickname(): string {
|
||||||
this._userId = userId;
|
return this._nickname;
|
||||||
this._accountSequence = accountSequence;
|
}
|
||||||
this._devices = devices;
|
get avatarUrl(): string | null {
|
||||||
this._phoneNumber = phoneNumber;
|
return this._avatarUrl;
|
||||||
this._nickname = nickname;
|
}
|
||||||
this._avatarUrl = avatarUrl;
|
get inviterSequence(): AccountSequence | null {
|
||||||
this._inviterSequence = inviterSequence;
|
return this._inviterSequence;
|
||||||
this._referralCode = referralCode;
|
}
|
||||||
this._walletAddresses = walletAddresses;
|
get referralCode(): ReferralCode {
|
||||||
this._kycInfo = kycInfo;
|
return this._referralCode;
|
||||||
this._kycStatus = kycStatus;
|
}
|
||||||
this._status = status;
|
get kycInfo(): KYCInfo | null {
|
||||||
this._registeredAt = registeredAt;
|
return this._kycInfo;
|
||||||
this._lastLoginAt = lastLoginAt;
|
}
|
||||||
this._updatedAt = updatedAt;
|
get kycStatus(): KYCStatus {
|
||||||
}
|
return this._kycStatus;
|
||||||
|
}
|
||||||
static createAutomatic(params: {
|
get status(): AccountStatus {
|
||||||
accountSequence: AccountSequence;
|
return this._status;
|
||||||
initialDeviceId: string;
|
}
|
||||||
deviceName?: string;
|
get registeredAt(): Date {
|
||||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
return this._registeredAt;
|
||||||
inviterSequence: AccountSequence | null;
|
}
|
||||||
nickname?: string;
|
get lastLoginAt(): Date | null {
|
||||||
avatarSvg?: string;
|
return this._lastLoginAt;
|
||||||
}): UserAccount {
|
}
|
||||||
const devices = new Map<string, DeviceInfo>();
|
get updatedAt(): Date {
|
||||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
return this._updatedAt;
|
||||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
}
|
||||||
params.deviceInfo, // 传递完整的 JSON
|
get isActive(): boolean {
|
||||||
));
|
return this._status === AccountStatus.ACTIVE;
|
||||||
|
}
|
||||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
get isKYCVerified(): boolean {
|
||||||
const nickname = params.nickname || `用户${params.accountSequence.dailySequence}`;
|
return this._kycStatus === KYCStatus.VERIFIED;
|
||||||
const avatarUrl = params.avatarSvg || null;
|
}
|
||||||
|
get domainEvents(): DomainEvent[] {
|
||||||
const account = new UserAccount(
|
return [...this._domainEvents];
|
||||||
UserId.create(0), params.accountSequence, devices, null,
|
}
|
||||||
nickname, avatarUrl, params.inviterSequence,
|
|
||||||
ReferralCode.generate(),
|
private constructor(
|
||||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
userId: UserId,
|
||||||
new Date(), null, new Date(),
|
accountSequence: AccountSequence,
|
||||||
);
|
devices: Map<string, DeviceInfo>,
|
||||||
|
phoneNumber: PhoneNumber | null,
|
||||||
account.addDomainEvent(new UserAccountAutoCreatedEvent({
|
nickname: string,
|
||||||
userId: account.userId.toString(),
|
avatarUrl: string | null,
|
||||||
accountSequence: params.accountSequence.value,
|
inviterSequence: AccountSequence | null,
|
||||||
referralCode: account._referralCode.value, // 用户的推荐码
|
referralCode: ReferralCode,
|
||||||
initialDeviceId: params.initialDeviceId,
|
walletAddresses: Map<ChainType, WalletAddress>,
|
||||||
inviterSequence: params.inviterSequence?.value || null,
|
kycInfo: KYCInfo | null,
|
||||||
registeredAt: account._registeredAt,
|
kycStatus: KYCStatus,
|
||||||
}));
|
status: AccountStatus,
|
||||||
|
registeredAt: Date,
|
||||||
return account;
|
lastLoginAt: Date | null,
|
||||||
}
|
updatedAt: Date,
|
||||||
|
) {
|
||||||
static create(params: {
|
this._userId = userId;
|
||||||
accountSequence: AccountSequence;
|
this._accountSequence = accountSequence;
|
||||||
phoneNumber: PhoneNumber;
|
this._devices = devices;
|
||||||
initialDeviceId: string;
|
this._phoneNumber = phoneNumber;
|
||||||
deviceName?: string;
|
this._nickname = nickname;
|
||||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
this._avatarUrl = avatarUrl;
|
||||||
inviterSequence: AccountSequence | null;
|
this._inviterSequence = inviterSequence;
|
||||||
}): UserAccount {
|
this._referralCode = referralCode;
|
||||||
const devices = new Map<string, DeviceInfo>();
|
this._walletAddresses = walletAddresses;
|
||||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
this._kycInfo = kycInfo;
|
||||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
this._kycStatus = kycStatus;
|
||||||
params.deviceInfo,
|
this._status = status;
|
||||||
));
|
this._registeredAt = registeredAt;
|
||||||
|
this._lastLoginAt = lastLoginAt;
|
||||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
this._updatedAt = updatedAt;
|
||||||
const account = new UserAccount(
|
}
|
||||||
UserId.create(0), params.accountSequence, devices, params.phoneNumber,
|
|
||||||
`用户${params.accountSequence.dailySequence}`, null, params.inviterSequence,
|
static createAutomatic(params: {
|
||||||
ReferralCode.generate(),
|
accountSequence: AccountSequence;
|
||||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
initialDeviceId: string;
|
||||||
new Date(), null, new Date(),
|
deviceName?: string;
|
||||||
);
|
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||||
|
inviterSequence: AccountSequence | null;
|
||||||
account.addDomainEvent(new UserAccountCreatedEvent({
|
nickname?: string;
|
||||||
userId: account.userId.toString(),
|
avatarSvg?: string;
|
||||||
accountSequence: params.accountSequence.value,
|
}): UserAccount {
|
||||||
referralCode: account._referralCode.value, // 用户的推荐码
|
const devices = new Map<string, DeviceInfo>();
|
||||||
phoneNumber: params.phoneNumber.value,
|
devices.set(
|
||||||
initialDeviceId: params.initialDeviceId,
|
params.initialDeviceId,
|
||||||
inviterSequence: params.inviterSequence?.value || null,
|
new DeviceInfo(
|
||||||
registeredAt: account._registeredAt,
|
params.initialDeviceId,
|
||||||
}));
|
params.deviceName || '未命名设备',
|
||||||
|
new Date(),
|
||||||
return account;
|
new Date(),
|
||||||
}
|
params.deviceInfo, // 传递完整的 JSON
|
||||||
|
),
|
||||||
static reconstruct(params: {
|
);
|
||||||
userId: string; accountSequence: string; devices: DeviceInfo[];
|
|
||||||
phoneNumber: string | null; nickname: string; avatarUrl: string | null;
|
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||||
inviterSequence: string | null; referralCode: string;
|
const nickname =
|
||||||
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null;
|
params.nickname || `用户${params.accountSequence.dailySequence}`;
|
||||||
kycStatus: KYCStatus; status: AccountStatus;
|
const avatarUrl = params.avatarSvg || null;
|
||||||
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
|
|
||||||
}): UserAccount {
|
const account = new UserAccount(
|
||||||
const deviceMap = new Map<string, DeviceInfo>();
|
UserId.create(0),
|
||||||
params.devices.forEach(d => deviceMap.set(d.deviceId, d));
|
params.accountSequence,
|
||||||
|
devices,
|
||||||
const walletMap = new Map<ChainType, WalletAddress>();
|
null,
|
||||||
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w));
|
nickname,
|
||||||
|
avatarUrl,
|
||||||
return new UserAccount(
|
params.inviterSequence,
|
||||||
UserId.create(params.userId),
|
ReferralCode.generate(),
|
||||||
AccountSequence.create(params.accountSequence),
|
new Map(),
|
||||||
deviceMap,
|
null,
|
||||||
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
KYCStatus.NOT_VERIFIED,
|
||||||
params.nickname,
|
AccountStatus.ACTIVE,
|
||||||
params.avatarUrl,
|
new Date(),
|
||||||
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null,
|
null,
|
||||||
ReferralCode.create(params.referralCode),
|
new Date(),
|
||||||
walletMap,
|
);
|
||||||
params.kycInfo,
|
|
||||||
params.kycStatus,
|
account.addDomainEvent(
|
||||||
params.status,
|
new UserAccountAutoCreatedEvent({
|
||||||
params.registeredAt,
|
userId: account.userId.toString(),
|
||||||
params.lastLoginAt,
|
accountSequence: params.accountSequence.value,
|
||||||
params.updatedAt,
|
referralCode: account._referralCode.value, // 用户的推荐码
|
||||||
);
|
initialDeviceId: params.initialDeviceId,
|
||||||
}
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
|
registeredAt: account._registeredAt,
|
||||||
addDevice(deviceId: string, deviceName?: string, deviceInfo?: Record<string, unknown>): void {
|
}),
|
||||||
this.ensureActive();
|
);
|
||||||
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
|
||||||
throw new DomainError('最多允许5个设备同时登录');
|
return account;
|
||||||
}
|
}
|
||||||
if (this._devices.has(deviceId)) {
|
|
||||||
const device = this._devices.get(deviceId)!;
|
static create(params: {
|
||||||
device.updateActivity();
|
accountSequence: AccountSequence;
|
||||||
if (deviceInfo) {
|
phoneNumber: PhoneNumber;
|
||||||
device.updateDeviceInfo(deviceInfo);
|
initialDeviceId: string;
|
||||||
}
|
deviceName?: string;
|
||||||
} else {
|
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||||
this._devices.set(deviceId, new DeviceInfo(
|
inviterSequence: AccountSequence | null;
|
||||||
deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo,
|
}): UserAccount {
|
||||||
));
|
const devices = new Map<string, DeviceInfo>();
|
||||||
this.addDomainEvent(new DeviceAddedEvent({
|
devices.set(
|
||||||
userId: this.userId.toString(),
|
params.initialDeviceId,
|
||||||
accountSequence: this.accountSequence.value,
|
new DeviceInfo(
|
||||||
deviceId,
|
params.initialDeviceId,
|
||||||
deviceName: deviceName || '未命名设备',
|
params.deviceName || '未命名设备',
|
||||||
}));
|
new Date(),
|
||||||
}
|
new Date(),
|
||||||
this._updatedAt = new Date();
|
params.deviceInfo,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
removeDevice(deviceId: string): void {
|
|
||||||
this.ensureActive();
|
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||||
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
|
const account = new UserAccount(
|
||||||
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
UserId.create(0),
|
||||||
this._devices.delete(deviceId);
|
params.accountSequence,
|
||||||
this._updatedAt = new Date();
|
devices,
|
||||||
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }));
|
params.phoneNumber,
|
||||||
}
|
`用户${params.accountSequence.dailySequence}`,
|
||||||
|
null,
|
||||||
isDeviceAuthorized(deviceId: string): boolean {
|
params.inviterSequence,
|
||||||
return this._devices.has(deviceId);
|
ReferralCode.generate(),
|
||||||
}
|
new Map(),
|
||||||
|
null,
|
||||||
getAllDevices(): DeviceInfo[] {
|
KYCStatus.NOT_VERIFIED,
|
||||||
return Array.from(this._devices.values());
|
AccountStatus.ACTIVE,
|
||||||
}
|
new Date(),
|
||||||
|
null,
|
||||||
updateProfile(params: { nickname?: string; avatarUrl?: string }): void {
|
new Date(),
|
||||||
this.ensureActive();
|
);
|
||||||
if (params.nickname) this._nickname = params.nickname;
|
|
||||||
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
|
account.addDomainEvent(
|
||||||
this._updatedAt = new Date();
|
new UserAccountCreatedEvent({
|
||||||
}
|
userId: account.userId.toString(),
|
||||||
|
accountSequence: params.accountSequence.value,
|
||||||
bindPhoneNumber(phoneNumber: PhoneNumber): void {
|
referralCode: account._referralCode.value, // 用户的推荐码
|
||||||
this.ensureActive();
|
phoneNumber: params.phoneNumber.value,
|
||||||
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
initialDeviceId: params.initialDeviceId,
|
||||||
this._phoneNumber = phoneNumber;
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
this._updatedAt = new Date();
|
registeredAt: account._registeredAt,
|
||||||
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.toString(), phoneNumber: phoneNumber.value }));
|
}),
|
||||||
}
|
);
|
||||||
|
|
||||||
bindWalletAddress(chainType: ChainType, address: string): void {
|
return account;
|
||||||
this.ensureActive();
|
}
|
||||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
|
||||||
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
|
static reconstruct(params: {
|
||||||
this._walletAddresses.set(chainType, walletAddress);
|
userId: string;
|
||||||
this._updatedAt = new Date();
|
accountSequence: string;
|
||||||
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.toString(), chainType, address }));
|
devices: DeviceInfo[];
|
||||||
}
|
phoneNumber: string | null;
|
||||||
|
nickname: string;
|
||||||
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
avatarUrl: string | null;
|
||||||
this.ensureActive();
|
inviterSequence: string | null;
|
||||||
for (const [chainType, wallet] of wallets) {
|
referralCode: string;
|
||||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
walletAddresses: WalletAddress[];
|
||||||
this._walletAddresses.set(chainType, wallet);
|
kycInfo: KYCInfo | null;
|
||||||
}
|
kycStatus: KYCStatus;
|
||||||
this._updatedAt = new Date();
|
status: AccountStatus;
|
||||||
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({
|
registeredAt: Date;
|
||||||
userId: this.userId.toString(),
|
lastLoginAt: Date | null;
|
||||||
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })),
|
updatedAt: Date;
|
||||||
}));
|
}): UserAccount {
|
||||||
}
|
const deviceMap = new Map<string, DeviceInfo>();
|
||||||
|
params.devices.forEach((d) => deviceMap.set(d.deviceId, d));
|
||||||
submitKYC(kycInfo: KYCInfo): void {
|
|
||||||
this.ensureActive();
|
const walletMap = new Map<ChainType, WalletAddress>();
|
||||||
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交');
|
params.walletAddresses.forEach((w) => walletMap.set(w.chainType, w));
|
||||||
this._kycInfo = kycInfo;
|
|
||||||
this._kycStatus = KYCStatus.PENDING;
|
return new UserAccount(
|
||||||
this._updatedAt = new Date();
|
UserId.create(params.userId),
|
||||||
this.addDomainEvent(new KYCSubmittedEvent({
|
AccountSequence.create(params.accountSequence),
|
||||||
userId: this.userId.toString(), realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber,
|
deviceMap,
|
||||||
}));
|
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
||||||
}
|
params.nickname,
|
||||||
|
params.avatarUrl,
|
||||||
approveKYC(): void {
|
params.inviterSequence
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC');
|
? AccountSequence.create(params.inviterSequence)
|
||||||
this._kycStatus = KYCStatus.VERIFIED;
|
: null,
|
||||||
this._updatedAt = new Date();
|
ReferralCode.create(params.referralCode),
|
||||||
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.toString(), verifiedAt: new Date() }));
|
walletMap,
|
||||||
}
|
params.kycInfo,
|
||||||
|
params.kycStatus,
|
||||||
rejectKYC(reason: string): void {
|
params.status,
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC');
|
params.registeredAt,
|
||||||
this._kycStatus = KYCStatus.REJECTED;
|
params.lastLoginAt,
|
||||||
this._updatedAt = new Date();
|
params.updatedAt,
|
||||||
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.toString(), reason }));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordLogin(): void {
|
addDevice(
|
||||||
this.ensureActive();
|
deviceId: string,
|
||||||
this._lastLoginAt = new Date();
|
deviceName?: string,
|
||||||
this._updatedAt = new Date();
|
deviceInfo?: Record<string, unknown>,
|
||||||
}
|
): void {
|
||||||
|
this.ensureActive();
|
||||||
freeze(reason: string): void {
|
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
||||||
if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结');
|
throw new DomainError('最多允许5个设备同时登录');
|
||||||
this._status = AccountStatus.FROZEN;
|
}
|
||||||
this._updatedAt = new Date();
|
if (this._devices.has(deviceId)) {
|
||||||
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }));
|
const device = this._devices.get(deviceId)!;
|
||||||
}
|
device.updateActivity();
|
||||||
|
if (deviceInfo) {
|
||||||
unfreeze(): void {
|
device.updateDeviceInfo(deviceInfo);
|
||||||
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结');
|
}
|
||||||
this._status = AccountStatus.ACTIVE;
|
} else {
|
||||||
this._updatedAt = new Date();
|
this._devices.set(
|
||||||
}
|
deviceId,
|
||||||
|
new DeviceInfo(
|
||||||
deactivate(): void {
|
deviceId,
|
||||||
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销');
|
deviceName || '未命名设备',
|
||||||
this._status = AccountStatus.DEACTIVATED;
|
new Date(),
|
||||||
this._updatedAt = new Date();
|
new Date(),
|
||||||
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.toString(), deactivatedAt: new Date() }));
|
deviceInfo,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
this.addDomainEvent(
|
||||||
return this._walletAddresses.get(chainType) || null;
|
new DeviceAddedEvent({
|
||||||
}
|
userId: this.userId.toString(),
|
||||||
|
accountSequence: this.accountSequence.value,
|
||||||
getAllWalletAddresses(): WalletAddress[] {
|
deviceId,
|
||||||
return Array.from(this._walletAddresses.values());
|
deviceName: deviceName || '未命名设备',
|
||||||
}
|
}),
|
||||||
|
);
|
||||||
private ensureActive(): void {
|
}
|
||||||
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
|
this._updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
private addDomainEvent(event: DomainEvent): void {
|
removeDevice(deviceId: string): void {
|
||||||
this._domainEvents.push(event);
|
this.ensureActive();
|
||||||
}
|
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
|
||||||
|
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
||||||
clearDomainEvents(): void {
|
this._devices.delete(deviceId);
|
||||||
this._domainEvents = [];
|
this._updatedAt = new Date();
|
||||||
}
|
this.addDomainEvent(
|
||||||
|
new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }),
|
||||||
/**
|
);
|
||||||
* 创建钱包生成事件(用于重试)
|
}
|
||||||
*
|
|
||||||
* 重新发布 UserAccountCreatedEvent 以触发 MPC 钱包生成流程
|
isDeviceAuthorized(deviceId: string): boolean {
|
||||||
* 这个方法是幂等的,可以安全地多次调用
|
return this._devices.has(deviceId);
|
||||||
*/
|
}
|
||||||
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
|
||||||
// 获取第一个设备的信息
|
getAllDevices(): DeviceInfo[] {
|
||||||
const firstDevice = this._devices.values().next().value as DeviceInfo | undefined;
|
return Array.from(this._devices.values());
|
||||||
|
}
|
||||||
return new UserAccountCreatedEvent({
|
|
||||||
userId: this._userId.toString(),
|
updateProfile(params: { nickname?: string; avatarUrl?: string }): void {
|
||||||
accountSequence: this._accountSequence.value,
|
this.ensureActive();
|
||||||
referralCode: this._referralCode.value,
|
if (params.nickname) this._nickname = params.nickname;
|
||||||
phoneNumber: this._phoneNumber?.value || null,
|
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
|
||||||
initialDeviceId: firstDevice?.deviceId || 'retry-unknown',
|
this._updatedAt = new Date();
|
||||||
deviceName: firstDevice?.deviceName || 'retry-device',
|
}
|
||||||
deviceInfo: firstDevice?.deviceInfo || null,
|
|
||||||
inviterReferralCode: null, // 重试时不需要
|
bindPhoneNumber(phoneNumber: PhoneNumber): void {
|
||||||
createdAt: new Date(),
|
this.ensureActive();
|
||||||
});
|
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
||||||
}
|
this._phoneNumber = phoneNumber;
|
||||||
}
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new PhoneNumberBoundEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
phoneNumber: phoneNumber.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindWalletAddress(chainType: ChainType, address: string): void {
|
||||||
|
this.ensureActive();
|
||||||
|
if (this._walletAddresses.has(chainType))
|
||||||
|
throw new DomainError(`已绑定${chainType}地址`);
|
||||||
|
const walletAddress = WalletAddress.create({
|
||||||
|
userId: this.userId,
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
this._walletAddresses.set(chainType, walletAddress);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new WalletAddressBoundEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
||||||
|
this.ensureActive();
|
||||||
|
for (const [chainType, wallet] of wallets) {
|
||||||
|
if (this._walletAddresses.has(chainType))
|
||||||
|
throw new DomainError(`已绑定${chainType}地址`);
|
||||||
|
this._walletAddresses.set(chainType, wallet);
|
||||||
|
}
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new MultipleWalletAddressesBoundEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({
|
||||||
|
chainType,
|
||||||
|
address: wallet.address,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitKYC(kycInfo: KYCInfo): void {
|
||||||
|
this.ensureActive();
|
||||||
|
if (this._kycStatus === KYCStatus.VERIFIED)
|
||||||
|
throw new DomainError('已通过KYC认证,不可重复提交');
|
||||||
|
this._kycInfo = kycInfo;
|
||||||
|
this._kycStatus = KYCStatus.PENDING;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new KYCSubmittedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
realName: kycInfo.realName,
|
||||||
|
idCardNumber: kycInfo.idCardNumber,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveKYC(): void {
|
||||||
|
if (this._kycStatus !== KYCStatus.PENDING)
|
||||||
|
throw new DomainError('只有待审核状态才能通过KYC');
|
||||||
|
this._kycStatus = KYCStatus.VERIFIED;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new KYCVerifiedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectKYC(reason: string): void {
|
||||||
|
if (this._kycStatus !== KYCStatus.PENDING)
|
||||||
|
throw new DomainError('只有待审核状态才能拒绝KYC');
|
||||||
|
this._kycStatus = KYCStatus.REJECTED;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new KYCRejectedEvent({ userId: this.userId.toString(), reason }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordLogin(): void {
|
||||||
|
this.ensureActive();
|
||||||
|
this._lastLoginAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
freeze(reason: string): void {
|
||||||
|
if (this._status === AccountStatus.FROZEN)
|
||||||
|
throw new DomainError('账户已冻结');
|
||||||
|
this._status = AccountStatus.FROZEN;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unfreeze(): void {
|
||||||
|
if (this._status !== AccountStatus.FROZEN)
|
||||||
|
throw new DomainError('账户未冻结');
|
||||||
|
this._status = AccountStatus.ACTIVE;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(): void {
|
||||||
|
if (this._status === AccountStatus.DEACTIVATED)
|
||||||
|
throw new DomainError('账户已注销');
|
||||||
|
this._status = AccountStatus.DEACTIVATED;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(
|
||||||
|
new UserAccountDeactivatedEvent({
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
||||||
|
return this._walletAddresses.get(chainType) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllWalletAddresses(): WalletAddress[] {
|
||||||
|
return Array.from(this._walletAddresses.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureActive(): void {
|
||||||
|
if (this._status !== AccountStatus.ACTIVE)
|
||||||
|
throw new DomainError('账户已冻结或注销');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addDomainEvent(event: DomainEvent): void {
|
||||||
|
this._domainEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDomainEvents(): void {
|
||||||
|
this._domainEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建钱包生成事件(用于重试)
|
||||||
|
*
|
||||||
|
* 重新发布 UserAccountCreatedEvent 以触发 MPC 钱包生成流程
|
||||||
|
* 这个方法是幂等的,可以安全地多次调用
|
||||||
|
*/
|
||||||
|
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
||||||
|
// 获取第一个设备的信息
|
||||||
|
const firstDevice = this._devices.values().next().value as
|
||||||
|
| DeviceInfo
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return new UserAccountCreatedEvent({
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
accountSequence: this._accountSequence.value,
|
||||||
|
referralCode: this._referralCode.value,
|
||||||
|
phoneNumber: this._phoneNumber?.value || null,
|
||||||
|
initialDeviceId: firstDevice?.deviceId || 'retry-unknown',
|
||||||
|
deviceName: firstDevice?.deviceName || 'retry-device',
|
||||||
|
deviceInfo: firstDevice?.deviceInfo || null,
|
||||||
|
inviterReferralCode: null, // 重试时不需要
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { UserAccount } from './user-account.aggregate';
|
import { UserAccount } from './user-account.aggregate';
|
||||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAccountFactory {
|
export class UserAccountFactory {
|
||||||
createAutomatic(params: {
|
createAutomatic(params: {
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
inviterSequence: AccountSequence | null;
|
inviterSequence: AccountSequence | null;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
return UserAccount.createAutomatic(params);
|
return UserAccount.createAutomatic(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(params: {
|
create(params: {
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
phoneNumber: PhoneNumber;
|
phoneNumber: PhoneNumber;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
inviterSequence: AccountSequence | null;
|
inviterSequence: AccountSequence | null;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
return UserAccount.create(params);
|
return UserAccount.create(params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AccountSequenceGeneratorService, UserValidatorService } from './services';
|
import {
|
||||||
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
AccountSequenceGeneratorService,
|
||||||
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
UserValidatorService,
|
||||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
} from './services';
|
||||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
||||||
|
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
||||||
@Module({
|
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||||
imports: [InfrastructureModule],
|
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
providers: [
|
|
||||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
@Module({
|
||||||
AccountSequenceGeneratorService,
|
imports: [InfrastructureModule],
|
||||||
UserValidatorService,
|
providers: [
|
||||||
UserAccountFactory,
|
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||||
],
|
AccountSequenceGeneratorService,
|
||||||
exports: [
|
UserValidatorService,
|
||||||
USER_ACCOUNT_REPOSITORY,
|
UserAccountFactory,
|
||||||
AccountSequenceGeneratorService,
|
],
|
||||||
UserValidatorService,
|
exports: [
|
||||||
UserAccountFactory,
|
USER_ACCOUNT_REPOSITORY,
|
||||||
],
|
AccountSequenceGeneratorService,
|
||||||
})
|
UserValidatorService,
|
||||||
export class DomainModule {}
|
UserAccountFactory,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DomainModule {}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from './wallet-address.entity';
|
export * from './wallet-address.entity';
|
||||||
|
|
|
||||||
|
|
@ -1,286 +1,327 @@
|
||||||
import { HDKey } from '@scure/bip32';
|
import { HDKey } from '@scure/bip32';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { bech32 } from 'bech32';
|
import { bech32 } from 'bech32';
|
||||||
import { Wallet } from 'ethers';
|
import { Wallet } from 'ethers';
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import {
|
import {
|
||||||
AddressId,
|
AddressId,
|
||||||
UserId,
|
UserId,
|
||||||
ChainType,
|
ChainType,
|
||||||
CHAIN_CONFIG,
|
CHAIN_CONFIG,
|
||||||
AddressStatus,
|
AddressStatus,
|
||||||
Mnemonic,
|
Mnemonic,
|
||||||
MnemonicEncryption,
|
MnemonicEncryption,
|
||||||
} from '@/domain/value-objects';
|
} from '@/domain/value-objects';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPC 签名信息
|
* MPC 签名信息
|
||||||
* 64 bytes hex (R 32 bytes + S 32 bytes)
|
* 64 bytes hex (R 32 bytes + S 32 bytes)
|
||||||
*/
|
*/
|
||||||
export type MpcSignature = string;
|
export type MpcSignature = string;
|
||||||
|
|
||||||
export class WalletAddress {
|
export class WalletAddress {
|
||||||
private readonly _addressId: AddressId;
|
private readonly _addressId: AddressId;
|
||||||
private readonly _userId: UserId;
|
private readonly _userId: UserId;
|
||||||
private readonly _chainType: ChainType;
|
private readonly _chainType: ChainType;
|
||||||
private readonly _address: string;
|
private readonly _address: string;
|
||||||
private readonly _publicKey: string; // MPC 公钥
|
private readonly _publicKey: string; // MPC 公钥
|
||||||
private readonly _addressDigest: string; // 地址摘要
|
private readonly _addressDigest: string; // 地址摘要
|
||||||
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
||||||
private _status: AddressStatus;
|
private _status: AddressStatus;
|
||||||
private readonly _boundAt: Date;
|
private readonly _boundAt: Date;
|
||||||
|
|
||||||
get addressId(): AddressId { return this._addressId; }
|
get addressId(): AddressId {
|
||||||
get userId(): UserId { return this._userId; }
|
return this._addressId;
|
||||||
get chainType(): ChainType { return this._chainType; }
|
}
|
||||||
get address(): string { return this._address; }
|
get userId(): UserId {
|
||||||
get publicKey(): string { return this._publicKey; }
|
return this._userId;
|
||||||
get addressDigest(): string { return this._addressDigest; }
|
}
|
||||||
get mpcSignature(): MpcSignature { return this._mpcSignature; }
|
get chainType(): ChainType {
|
||||||
get status(): AddressStatus { return this._status; }
|
return this._chainType;
|
||||||
get boundAt(): Date { return this._boundAt; }
|
}
|
||||||
|
get address(): string {
|
||||||
private constructor(
|
return this._address;
|
||||||
addressId: AddressId,
|
}
|
||||||
userId: UserId,
|
get publicKey(): string {
|
||||||
chainType: ChainType,
|
return this._publicKey;
|
||||||
address: string,
|
}
|
||||||
publicKey: string,
|
get addressDigest(): string {
|
||||||
addressDigest: string,
|
return this._addressDigest;
|
||||||
mpcSignature: MpcSignature,
|
}
|
||||||
status: AddressStatus,
|
get mpcSignature(): MpcSignature {
|
||||||
boundAt: Date,
|
return this._mpcSignature;
|
||||||
) {
|
}
|
||||||
this._addressId = addressId;
|
get status(): AddressStatus {
|
||||||
this._userId = userId;
|
return this._status;
|
||||||
this._chainType = chainType;
|
}
|
||||||
this._address = address;
|
get boundAt(): Date {
|
||||||
this._publicKey = publicKey;
|
return this._boundAt;
|
||||||
this._addressDigest = addressDigest;
|
}
|
||||||
this._mpcSignature = mpcSignature;
|
|
||||||
this._status = status;
|
private constructor(
|
||||||
this._boundAt = boundAt;
|
addressId: AddressId,
|
||||||
}
|
userId: UserId,
|
||||||
|
chainType: ChainType,
|
||||||
/**
|
address: string,
|
||||||
* 创建 MPC 钱包地址
|
publicKey: string,
|
||||||
*
|
addressDigest: string,
|
||||||
* @param params 包含 MPC 签名验证信息的参数
|
mpcSignature: MpcSignature,
|
||||||
*/
|
status: AddressStatus,
|
||||||
static createMpc(params: {
|
boundAt: Date,
|
||||||
userId: UserId;
|
) {
|
||||||
chainType: ChainType;
|
this._addressId = addressId;
|
||||||
address: string;
|
this._userId = userId;
|
||||||
publicKey: string;
|
this._chainType = chainType;
|
||||||
addressDigest: string;
|
this._address = address;
|
||||||
signature: MpcSignature;
|
this._publicKey = publicKey;
|
||||||
}): WalletAddress {
|
this._addressDigest = addressDigest;
|
||||||
if (!this.validateEvmAddress(params.address)) {
|
this._mpcSignature = mpcSignature;
|
||||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
this._status = status;
|
||||||
}
|
this._boundAt = boundAt;
|
||||||
return new WalletAddress(
|
}
|
||||||
AddressId.generate(),
|
|
||||||
params.userId,
|
/**
|
||||||
params.chainType,
|
* 创建 MPC 钱包地址
|
||||||
params.address,
|
*
|
||||||
params.publicKey,
|
* @param params 包含 MPC 签名验证信息的参数
|
||||||
params.addressDigest,
|
*/
|
||||||
params.signature,
|
static createMpc(params: {
|
||||||
AddressStatus.ACTIVE,
|
userId: UserId;
|
||||||
new Date(),
|
chainType: ChainType;
|
||||||
);
|
address: string;
|
||||||
}
|
publicKey: string;
|
||||||
|
addressDigest: string;
|
||||||
/**
|
signature: MpcSignature;
|
||||||
* 从数据库重建实体
|
}): WalletAddress {
|
||||||
*/
|
if (!this.validateEvmAddress(params.address)) {
|
||||||
static reconstruct(params: {
|
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||||
addressId: string;
|
}
|
||||||
userId: string;
|
return new WalletAddress(
|
||||||
chainType: ChainType;
|
AddressId.generate(),
|
||||||
address: string;
|
params.userId,
|
||||||
publicKey: string;
|
params.chainType,
|
||||||
addressDigest: string;
|
params.address,
|
||||||
mpcSignature: string; // 64 bytes hex
|
params.publicKey,
|
||||||
status: AddressStatus;
|
params.addressDigest,
|
||||||
boundAt: Date;
|
params.signature,
|
||||||
}): WalletAddress {
|
AddressStatus.ACTIVE,
|
||||||
return new WalletAddress(
|
new Date(),
|
||||||
AddressId.create(params.addressId),
|
);
|
||||||
UserId.create(params.userId),
|
}
|
||||||
params.chainType,
|
|
||||||
params.address,
|
/**
|
||||||
params.publicKey,
|
* 从数据库重建实体
|
||||||
params.addressDigest,
|
*/
|
||||||
params.mpcSignature,
|
static reconstruct(params: {
|
||||||
params.status,
|
addressId: string;
|
||||||
params.boundAt,
|
userId: string;
|
||||||
);
|
chainType: ChainType;
|
||||||
}
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
disable(): void {
|
addressDigest: string;
|
||||||
this._status = AddressStatus.DISABLED;
|
mpcSignature: string; // 64 bytes hex
|
||||||
}
|
status: AddressStatus;
|
||||||
|
boundAt: Date;
|
||||||
enable(): void {
|
}): WalletAddress {
|
||||||
this._status = AddressStatus.ACTIVE;
|
return new WalletAddress(
|
||||||
}
|
AddressId.create(params.addressId),
|
||||||
|
UserId.create(params.userId),
|
||||||
/**
|
params.chainType,
|
||||||
* 验证签名是否有效
|
params.address,
|
||||||
* 用于检测地址是否被篡改
|
params.publicKey,
|
||||||
*/
|
params.addressDigest,
|
||||||
async verifySignature(): Promise<boolean> {
|
params.mpcSignature,
|
||||||
try {
|
params.status,
|
||||||
const { ethers } = await import('ethers');
|
params.boundAt,
|
||||||
|
);
|
||||||
// 计算预期的摘要
|
}
|
||||||
const expectedDigest = this.computeDigest();
|
|
||||||
if (expectedDigest !== this._addressDigest) {
|
disable(): void {
|
||||||
return false;
|
this._status = AddressStatus.DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars
|
enable(): void {
|
||||||
if (this._mpcSignature.length !== 128) {
|
this._status = AddressStatus.ACTIVE;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
const r = '0x' + this._mpcSignature.slice(0, 64);
|
* 验证签名是否有效
|
||||||
const s = '0x' + this._mpcSignature.slice(64, 128);
|
* 用于检测地址是否被篡改
|
||||||
const digestBytes = Buffer.from(this._addressDigest, 'hex');
|
*/
|
||||||
|
async verifySignature(): Promise<boolean> {
|
||||||
// 尝试两种 recovery id
|
try {
|
||||||
for (const v of [27, 28]) {
|
const { ethers } = await import('ethers');
|
||||||
try {
|
|
||||||
const sig = ethers.Signature.from({ r, s, v });
|
// 计算预期的摘要
|
||||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
const expectedDigest = this.computeDigest();
|
||||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
if (expectedDigest !== this._addressDigest) {
|
||||||
|
return false;
|
||||||
if (compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase()) {
|
}
|
||||||
return true;
|
|
||||||
}
|
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars
|
||||||
} catch {
|
if (this._mpcSignature.length !== 128) {
|
||||||
// 尝试下一个 v 值
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const r = '0x' + this._mpcSignature.slice(0, 64);
|
||||||
return false;
|
const s = '0x' + this._mpcSignature.slice(64, 128);
|
||||||
} catch {
|
const digestBytes = Buffer.from(this._addressDigest, 'hex');
|
||||||
return false;
|
|
||||||
}
|
// 尝试两种 recovery id
|
||||||
}
|
for (const v of [27, 28]) {
|
||||||
|
try {
|
||||||
/**
|
const sig = ethers.Signature.from({ r, s, v });
|
||||||
* 计算地址摘要
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||||
*/
|
digestBytes,
|
||||||
private computeDigest(): string {
|
sig,
|
||||||
const message = `${this._chainType}:${this._address.toLowerCase()}`;
|
);
|
||||||
return createHash('sha256').update(message).digest('hex');
|
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||||
}
|
recoveredPubKey,
|
||||||
|
true,
|
||||||
/**
|
);
|
||||||
* 验证 EVM 地址格式
|
|
||||||
*/
|
if (
|
||||||
private static validateEvmAddress(address: string): boolean {
|
compressedRecovered.slice(2).toLowerCase() ===
|
||||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
this._publicKey.toLowerCase()
|
||||||
}
|
) {
|
||||||
|
return true;
|
||||||
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
}
|
||||||
|
} catch {
|
||||||
/**
|
// 尝试下一个 v 值
|
||||||
* 创建钱包地址(简化版,用于从 blockchain-service 接收地址)
|
}
|
||||||
*/
|
}
|
||||||
static create(params: {
|
|
||||||
userId: UserId;
|
return false;
|
||||||
chainType: ChainType;
|
} catch {
|
||||||
address: string;
|
return false;
|
||||||
publicKey?: string; // 公钥
|
}
|
||||||
}): WalletAddress {
|
}
|
||||||
if (!this.validateAddress(params.chainType, params.address)) {
|
|
||||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
/**
|
||||||
}
|
* 计算地址摘要
|
||||||
return new WalletAddress(
|
*/
|
||||||
AddressId.generate(),
|
private computeDigest(): string {
|
||||||
params.userId,
|
const message = `${this._chainType}:${this._address.toLowerCase()}`;
|
||||||
params.chainType,
|
return createHash('sha256').update(message).digest('hex');
|
||||||
params.address,
|
}
|
||||||
params.publicKey || '',
|
|
||||||
'',
|
/**
|
||||||
'', // empty signature
|
* 验证 EVM 地址格式
|
||||||
AddressStatus.ACTIVE,
|
*/
|
||||||
new Date(),
|
private static validateEvmAddress(address: string): boolean {
|
||||||
);
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
||||||
* @deprecated MPC 模式下不再使用助记词
|
|
||||||
*/
|
/**
|
||||||
static createFromMnemonic(params: {
|
* 创建钱包地址(简化版,用于从 blockchain-service 接收地址)
|
||||||
userId: UserId;
|
*/
|
||||||
chainType: ChainType;
|
static create(params: {
|
||||||
mnemonic: Mnemonic;
|
userId: UserId;
|
||||||
encryptionKey: string;
|
chainType: ChainType;
|
||||||
}): WalletAddress {
|
address: string;
|
||||||
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
publicKey?: string; // 公钥
|
||||||
return new WalletAddress(
|
}): WalletAddress {
|
||||||
AddressId.generate(),
|
if (!this.validateAddress(params.chainType, params.address)) {
|
||||||
params.userId,
|
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||||
params.chainType,
|
}
|
||||||
address,
|
return new WalletAddress(
|
||||||
'',
|
AddressId.generate(),
|
||||||
'',
|
params.userId,
|
||||||
'', // empty signature
|
params.chainType,
|
||||||
AddressStatus.ACTIVE,
|
params.address,
|
||||||
new Date(),
|
params.publicKey || '',
|
||||||
);
|
'',
|
||||||
}
|
'', // empty signature
|
||||||
|
AddressStatus.ACTIVE,
|
||||||
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
new Date(),
|
||||||
const seed = mnemonic.toSeed();
|
);
|
||||||
const config = CHAIN_CONFIG[chainType];
|
}
|
||||||
|
|
||||||
switch (chainType) {
|
/**
|
||||||
case ChainType.KAVA:
|
* @deprecated MPC 模式下不再使用助记词
|
||||||
case ChainType.DST:
|
*/
|
||||||
return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix);
|
static createFromMnemonic(params: {
|
||||||
case ChainType.BSC:
|
userId: UserId;
|
||||||
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
chainType: ChainType;
|
||||||
default:
|
mnemonic: Mnemonic;
|
||||||
throw new DomainError(`不支持的链类型: ${chainType}`);
|
encryptionKey: string;
|
||||||
}
|
}): WalletAddress {
|
||||||
}
|
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
||||||
|
return new WalletAddress(
|
||||||
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string {
|
AddressId.generate(),
|
||||||
const hdkey = HDKey.fromMasterSeed(seed);
|
params.userId,
|
||||||
const childKey = hdkey.derive(path);
|
params.chainType,
|
||||||
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
address,
|
||||||
|
'',
|
||||||
const hash = createHash('sha256').update(childKey.publicKey).digest();
|
'',
|
||||||
const addressHash = createHash('ripemd160').update(hash).digest();
|
'', // empty signature
|
||||||
const words = bech32.toWords(addressHash);
|
AddressStatus.ACTIVE,
|
||||||
return bech32.encode(prefix, words);
|
new Date(),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
private static deriveEVMAddress(seed: Buffer, path: string): string {
|
|
||||||
const hdkey = HDKey.fromMasterSeed(seed);
|
private static deriveAddress(
|
||||||
const childKey = hdkey.derive(path);
|
chainType: ChainType,
|
||||||
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
|
mnemonic: Mnemonic,
|
||||||
|
): string {
|
||||||
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
|
const seed = mnemonic.toSeed();
|
||||||
return wallet.address;
|
const config = CHAIN_CONFIG[chainType];
|
||||||
}
|
|
||||||
|
switch (chainType) {
|
||||||
private static validateAddress(chainType: ChainType, address: string): boolean {
|
case ChainType.KAVA:
|
||||||
switch (chainType) {
|
case ChainType.DST:
|
||||||
case ChainType.KAVA:
|
return this.deriveCosmosAddress(
|
||||||
case ChainType.BSC:
|
Buffer.from(seed),
|
||||||
// KAVA 和 BSC 都使用 EVM 地址格式
|
config.derivationPath,
|
||||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
config.prefix,
|
||||||
case ChainType.DST:
|
);
|
||||||
// DST 使用 Cosmos bech32 格式
|
case ChainType.BSC:
|
||||||
return /^dst1[a-z0-9]{38}$/.test(address);
|
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
||||||
default:
|
default:
|
||||||
return false;
|
throw new DomainError(`不支持的链类型: ${chainType}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static deriveCosmosAddress(
|
||||||
|
seed: Buffer,
|
||||||
|
path: string,
|
||||||
|
prefix: string,
|
||||||
|
): string {
|
||||||
|
const hdkey = HDKey.fromMasterSeed(seed);
|
||||||
|
const childKey = hdkey.derive(path);
|
||||||
|
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
||||||
|
|
||||||
|
const hash = createHash('sha256').update(childKey.publicKey).digest();
|
||||||
|
const addressHash = createHash('ripemd160').update(hash).digest();
|
||||||
|
const words = bech32.toWords(addressHash);
|
||||||
|
return bech32.encode(prefix, words);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deriveEVMAddress(seed: Buffer, path: string): string {
|
||||||
|
const hdkey = HDKey.fromMasterSeed(seed);
|
||||||
|
const childKey = hdkey.derive(path);
|
||||||
|
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
|
||||||
|
|
||||||
|
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
|
||||||
|
return wallet.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static validateAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): boolean {
|
||||||
|
switch (chainType) {
|
||||||
|
case ChainType.KAVA:
|
||||||
|
case ChainType.BSC:
|
||||||
|
// KAVA 和 BSC 都使用 EVM 地址格式
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
case ChainType.DST:
|
||||||
|
// DST 使用 Cosmos bech32 格式
|
||||||
|
return /^dst1[a-z0-9]{38}$/.test(address);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export enum AccountStatus {
|
export enum AccountStatus {
|
||||||
ACTIVE = 'ACTIVE',
|
ACTIVE = 'ACTIVE',
|
||||||
FROZEN = 'FROZEN',
|
FROZEN = 'FROZEN',
|
||||||
DEACTIVATED = 'DEACTIVATED',
|
DEACTIVATED = 'DEACTIVATED',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
export enum ChainType {
|
export enum ChainType {
|
||||||
KAVA = 'KAVA',
|
KAVA = 'KAVA',
|
||||||
DST = 'DST',
|
DST = 'DST',
|
||||||
BSC = 'BSC',
|
BSC = 'BSC',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHAIN_CONFIG = {
|
export const CHAIN_CONFIG = {
|
||||||
[ChainType.KAVA]: {
|
[ChainType.KAVA]: {
|
||||||
prefix: 'kava',
|
prefix: 'kava',
|
||||||
derivationPath: "m/44'/459'/0'/0/0",
|
derivationPath: "m/44'/459'/0'/0/0",
|
||||||
},
|
},
|
||||||
[ChainType.DST]: {
|
[ChainType.DST]: {
|
||||||
prefix: 'dst',
|
prefix: 'dst',
|
||||||
derivationPath: "m/44'/118'/0'/0/0",
|
derivationPath: "m/44'/118'/0'/0/0",
|
||||||
},
|
},
|
||||||
[ChainType.BSC]: {
|
[ChainType.BSC]: {
|
||||||
prefix: '0x',
|
prefix: '0x',
|
||||||
derivationPath: "m/44'/60'/0'/0/0",
|
derivationPath: "m/44'/60'/0'/0/0",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export * from './chain-type.enum';
|
export * from './chain-type.enum';
|
||||||
export * from './kyc-status.enum';
|
export * from './kyc-status.enum';
|
||||||
export * from './account-status.enum';
|
export * from './account-status.enum';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export enum KYCStatus {
|
export enum KYCStatus {
|
||||||
NOT_VERIFIED = 'NOT_VERIFIED',
|
NOT_VERIFIED = 'NOT_VERIFIED',
|
||||||
PENDING = 'PENDING',
|
PENDING = 'PENDING',
|
||||||
VERIFIED = 'VERIFIED',
|
VERIFIED = 'VERIFIED',
|
||||||
REJECTED = 'REJECTED',
|
REJECTED = 'REJECTED',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { DomainEvent } from './index';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class DeviceAddedEvent extends DomainEvent {
|
export class DeviceAddedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: number;
|
accountSequence: number;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'DeviceAdded';
|
return 'DeviceAdded';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export abstract class DomainEvent {
|
export abstract class DomainEvent {
|
||||||
public readonly eventId: string;
|
public readonly eventId: string;
|
||||||
public readonly occurredAt: Date;
|
public readonly occurredAt: Date;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.eventId = uuidv4();
|
this.eventId = uuidv4();
|
||||||
this.occurredAt = new Date();
|
this.occurredAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract get eventType(): string;
|
abstract get eventType(): string;
|
||||||
abstract get aggregateId(): string;
|
abstract get aggregateId(): string;
|
||||||
abstract get aggregateType(): string;
|
abstract get aggregateType(): string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,328 +1,344 @@
|
||||||
export abstract class DomainEvent {
|
export abstract class DomainEvent {
|
||||||
public readonly occurredAt: Date;
|
public readonly occurredAt: Date;
|
||||||
public readonly eventId: string;
|
public readonly eventId: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.occurredAt = new Date();
|
this.occurredAt = new Date();
|
||||||
this.eventId = crypto.randomUUID();
|
this.eventId = crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract get eventType(): string;
|
abstract get eventType(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAccountAutoCreatedEvent extends DomainEvent {
|
export class UserAccountAutoCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'UserAccountAutoCreated';
|
return 'UserAccountAutoCreated';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAccountCreatedEvent extends DomainEvent {
|
export class UserAccountCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'UserAccountCreated';
|
return 'UserAccountCreated';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeviceAddedEvent extends DomainEvent {
|
export class DeviceAddedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'DeviceAdded';
|
return 'DeviceAdded';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeviceRemovedEvent extends DomainEvent {
|
export class DeviceRemovedEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; deviceId: string }) {
|
constructor(public readonly payload: { userId: string; deviceId: string }) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'DeviceRemoved';
|
return 'DeviceRemoved';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
constructor(
|
||||||
super();
|
public readonly payload: { userId: string; phoneNumber: string },
|
||||||
}
|
) {
|
||||||
|
super();
|
||||||
get eventType(): string {
|
}
|
||||||
return 'PhoneNumberBound';
|
|
||||||
}
|
get eventType(): string {
|
||||||
}
|
return 'PhoneNumberBound';
|
||||||
|
}
|
||||||
export class WalletAddressBoundEvent extends DomainEvent {
|
}
|
||||||
constructor(public readonly payload: { userId: string; chainType: string; address: string }) {
|
|
||||||
super();
|
export class WalletAddressBoundEvent extends DomainEvent {
|
||||||
}
|
constructor(
|
||||||
|
public readonly payload: {
|
||||||
get eventType(): string {
|
userId: string;
|
||||||
return 'WalletAddressBound';
|
chainType: string;
|
||||||
}
|
address: string;
|
||||||
}
|
},
|
||||||
|
) {
|
||||||
export class MultipleWalletAddressesBoundEvent extends DomainEvent {
|
super();
|
||||||
constructor(
|
}
|
||||||
public readonly payload: {
|
|
||||||
userId: string;
|
get eventType(): string {
|
||||||
addresses: Array<{ chainType: string; address: string }>;
|
return 'WalletAddressBound';
|
||||||
},
|
}
|
||||||
) {
|
}
|
||||||
super();
|
|
||||||
}
|
export class MultipleWalletAddressesBoundEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
get eventType(): string {
|
public readonly payload: {
|
||||||
return 'MultipleWalletAddressesBound';
|
userId: string;
|
||||||
}
|
addresses: Array<{ chainType: string; address: string }>;
|
||||||
}
|
},
|
||||||
|
) {
|
||||||
export class KYCSubmittedEvent extends DomainEvent {
|
super();
|
||||||
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) {
|
}
|
||||||
super();
|
|
||||||
}
|
get eventType(): string {
|
||||||
|
return 'MultipleWalletAddressesBound';
|
||||||
get eventType(): string {
|
}
|
||||||
return 'KYCSubmitted';
|
}
|
||||||
}
|
|
||||||
}
|
export class KYCSubmittedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
export class KYCVerifiedEvent extends DomainEvent {
|
public readonly payload: {
|
||||||
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
|
userId: string;
|
||||||
super();
|
realName: string;
|
||||||
}
|
idCardNumber: string;
|
||||||
|
},
|
||||||
get eventType(): string {
|
) {
|
||||||
return 'KYCVerified';
|
super();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
get eventType(): string {
|
||||||
export class KYCRejectedEvent extends DomainEvent {
|
return 'KYCSubmitted';
|
||||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
}
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
export class KYCVerifiedEvent extends DomainEvent {
|
||||||
get eventType(): string {
|
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
|
||||||
return 'KYCRejected';
|
super();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
get eventType(): string {
|
||||||
export class UserAccountFrozenEvent extends DomainEvent {
|
return 'KYCVerified';
|
||||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
}
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
export class KYCRejectedEvent extends DomainEvent {
|
||||||
get eventType(): string {
|
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||||
return 'UserAccountFrozen';
|
super();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
get eventType(): string {
|
||||||
export class UserAccountDeactivatedEvent extends DomainEvent {
|
return 'KYCRejected';
|
||||||
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) {
|
}
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
export class UserAccountFrozenEvent extends DomainEvent {
|
||||||
get eventType(): string {
|
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||||
return 'UserAccountDeactivated';
|
super();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
get eventType(): string {
|
||||||
/**
|
return 'UserAccountFrozen';
|
||||||
* 用户资料更新事件
|
}
|
||||||
* 当用户更新昵称或头像时发布
|
}
|
||||||
*/
|
|
||||||
export class UserProfileUpdatedEvent extends DomainEvent {
|
export class UserAccountDeactivatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: { userId: string; deactivatedAt: Date },
|
||||||
userId: string;
|
) {
|
||||||
accountSequence: string;
|
super();
|
||||||
nickname: string | null;
|
}
|
||||||
avatarUrl: string | null;
|
|
||||||
updatedAt: Date;
|
get eventType(): string {
|
||||||
},
|
return 'UserAccountDeactivated';
|
||||||
) {
|
}
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
get eventType(): string {
|
* 用户资料更新事件
|
||||||
return 'UserProfileUpdated';
|
* 当用户更新昵称或头像时发布
|
||||||
}
|
*/
|
||||||
}
|
export class UserProfileUpdatedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
/**
|
public readonly payload: {
|
||||||
* MPC 密钥生成请求事件
|
userId: string;
|
||||||
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
accountSequence: string;
|
||||||
*
|
nickname: string | null;
|
||||||
* payload 格式需要与 mpc-service 的 KeygenRequestedPayload 匹配:
|
avatarUrl: string | null;
|
||||||
* - sessionId: 唯一会话ID
|
updatedAt: Date;
|
||||||
* - userId: 用户ID
|
},
|
||||||
* - accountSequence: 8位账户序列号 (用于关联恢复助记词)
|
) {
|
||||||
* - username: 用户名 (用于 mpc-system 标识)
|
super();
|
||||||
* - threshold: 签名阈值 (默认 2)
|
}
|
||||||
* - totalParties: 总参与方数 (默认 3)
|
|
||||||
* - requireDelegate: 是否需要委托分片 (默认 true)
|
get eventType(): string {
|
||||||
*/
|
return 'UserProfileUpdated';
|
||||||
export class MpcKeygenRequestedEvent extends DomainEvent {
|
}
|
||||||
constructor(
|
}
|
||||||
public readonly payload: {
|
|
||||||
sessionId: string;
|
/**
|
||||||
userId: string;
|
* MPC 密钥生成请求事件
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
||||||
username: string;
|
*
|
||||||
threshold: number;
|
* payload 格式需要与 mpc-service 的 KeygenRequestedPayload 匹配:
|
||||||
totalParties: number;
|
* - sessionId: 唯一会话ID
|
||||||
requireDelegate: boolean;
|
* - userId: 用户ID
|
||||||
},
|
* - accountSequence: 8位账户序列号 (用于关联恢复助记词)
|
||||||
) {
|
* - username: 用户名 (用于 mpc-system 标识)
|
||||||
super();
|
* - threshold: 签名阈值 (默认 2)
|
||||||
}
|
* - totalParties: 总参与方数 (默认 3)
|
||||||
|
* - requireDelegate: 是否需要委托分片 (默认 true)
|
||||||
get eventType(): string {
|
*/
|
||||||
return 'MpcKeygenRequested';
|
export class MpcKeygenRequestedEvent extends DomainEvent {
|
||||||
}
|
constructor(
|
||||||
}
|
public readonly payload: {
|
||||||
|
sessionId: string;
|
||||||
// ============ 账户恢复相关事件 ============
|
userId: string;
|
||||||
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
/**
|
username: string;
|
||||||
* 账户恢复成功事件 (审计日志)
|
threshold: number;
|
||||||
*/
|
totalParties: number;
|
||||||
export class AccountRecoveredEvent extends DomainEvent {
|
requireDelegate: boolean;
|
||||||
constructor(
|
},
|
||||||
public readonly payload: {
|
) {
|
||||||
userId: string;
|
super();
|
||||||
accountSequence: string;
|
}
|
||||||
recoveryMethod: 'mnemonic' | 'phone';
|
|
||||||
deviceId: string;
|
get eventType(): string {
|
||||||
deviceName?: string;
|
return 'MpcKeygenRequested';
|
||||||
ipAddress?: string;
|
}
|
||||||
userAgent?: string;
|
}
|
||||||
recoveredAt: Date;
|
|
||||||
},
|
// ============ 账户恢复相关事件 ============
|
||||||
) {
|
|
||||||
super();
|
/**
|
||||||
}
|
* 账户恢复成功事件 (审计日志)
|
||||||
|
*/
|
||||||
get eventType(): string {
|
export class AccountRecoveredEvent extends DomainEvent {
|
||||||
return 'AccountRecovered';
|
constructor(
|
||||||
}
|
public readonly payload: {
|
||||||
}
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
/**
|
recoveryMethod: 'mnemonic' | 'phone';
|
||||||
* 账户恢复失败事件 (审计日志)
|
deviceId: string;
|
||||||
*/
|
deviceName?: string;
|
||||||
export class AccountRecoveryFailedEvent extends DomainEvent {
|
ipAddress?: string;
|
||||||
constructor(
|
userAgent?: string;
|
||||||
public readonly payload: {
|
recoveredAt: Date;
|
||||||
accountSequence: string;
|
},
|
||||||
recoveryMethod: 'mnemonic' | 'phone';
|
) {
|
||||||
failureReason: string;
|
super();
|
||||||
deviceId?: string;
|
}
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
get eventType(): string {
|
||||||
attemptedAt: Date;
|
return 'AccountRecovered';
|
||||||
},
|
}
|
||||||
) {
|
}
|
||||||
super();
|
|
||||||
}
|
/**
|
||||||
|
* 账户恢复失败事件 (审计日志)
|
||||||
get eventType(): string {
|
*/
|
||||||
return 'AccountRecoveryFailed';
|
export class AccountRecoveryFailedEvent extends DomainEvent {
|
||||||
}
|
constructor(
|
||||||
}
|
public readonly payload: {
|
||||||
|
accountSequence: string;
|
||||||
/**
|
recoveryMethod: 'mnemonic' | 'phone';
|
||||||
* 助记词挂失事件 (审计日志)
|
failureReason: string;
|
||||||
*/
|
deviceId?: string;
|
||||||
export class MnemonicRevokedEvent extends DomainEvent {
|
ipAddress?: string;
|
||||||
constructor(
|
userAgent?: string;
|
||||||
public readonly payload: {
|
attemptedAt: Date;
|
||||||
userId: string;
|
},
|
||||||
accountSequence: string;
|
) {
|
||||||
reason: string;
|
super();
|
||||||
revokedAt: Date;
|
}
|
||||||
},
|
|
||||||
) {
|
get eventType(): string {
|
||||||
super();
|
return 'AccountRecoveryFailed';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
get eventType(): string {
|
|
||||||
return 'MnemonicRevoked';
|
/**
|
||||||
}
|
* 助记词挂失事件 (审计日志)
|
||||||
}
|
*/
|
||||||
|
export class MnemonicRevokedEvent extends DomainEvent {
|
||||||
/**
|
constructor(
|
||||||
* 账户解冻事件 (审计日志)
|
public readonly payload: {
|
||||||
*/
|
userId: string;
|
||||||
export class AccountUnfrozenEvent extends DomainEvent {
|
accountSequence: string;
|
||||||
constructor(
|
reason: string;
|
||||||
public readonly payload: {
|
revokedAt: Date;
|
||||||
userId: string;
|
},
|
||||||
accountSequence: string;
|
) {
|
||||||
verifyMethod: 'mnemonic' | 'phone';
|
super();
|
||||||
unfrozenAt: Date;
|
}
|
||||||
},
|
|
||||||
) {
|
get eventType(): string {
|
||||||
super();
|
return 'MnemonicRevoked';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
get eventType(): string {
|
|
||||||
return 'AccountUnfrozen';
|
/**
|
||||||
}
|
* 账户解冻事件 (审计日志)
|
||||||
}
|
*/
|
||||||
|
export class AccountUnfrozenEvent extends DomainEvent {
|
||||||
/**
|
constructor(
|
||||||
* 密钥轮换请求事件
|
public readonly payload: {
|
||||||
* 触发 MPC 系统进行密钥轮换
|
userId: string;
|
||||||
*/
|
accountSequence: string;
|
||||||
export class KeyRotationRequestedEvent extends DomainEvent {
|
verifyMethod: 'mnemonic' | 'phone';
|
||||||
constructor(
|
unfrozenAt: Date;
|
||||||
public readonly payload: {
|
},
|
||||||
sessionId: string;
|
) {
|
||||||
userId: string;
|
super();
|
||||||
accountSequence: string;
|
}
|
||||||
reason: string;
|
|
||||||
requestedAt: Date;
|
get eventType(): string {
|
||||||
},
|
return 'AccountUnfrozen';
|
||||||
) {
|
}
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
get eventType(): string {
|
* 密钥轮换请求事件
|
||||||
return 'KeyRotationRequested';
|
* 触发 MPC 系统进行密钥轮换
|
||||||
}
|
*/
|
||||||
}
|
export class KeyRotationRequestedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly payload: {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
reason: string;
|
||||||
|
requestedAt: Date;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventType(): string {
|
||||||
|
return 'KeyRotationRequested';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,31 @@
|
||||||
import { DomainEvent } from './domain-event.base';
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
export class KYCSubmittedEvent extends DomainEvent {
|
export class KYCSubmittedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly realName: string,
|
public readonly realName: string,
|
||||||
public readonly idCardNumber: string,
|
public readonly idCardNumber: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'KYCSubmitted';
|
return 'KYCSubmitted';
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateId(): string {
|
get aggregateId(): string {
|
||||||
return this.userId;
|
return this.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateType(): string {
|
get aggregateType(): string {
|
||||||
return 'UserAccount';
|
return 'UserAccount';
|
||||||
}
|
}
|
||||||
|
|
||||||
toPayload(): object {
|
toPayload(): object {
|
||||||
return {
|
return {
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
realName: this.realName,
|
realName: this.realName,
|
||||||
idCardNumber: this.idCardNumber,
|
idCardNumber: this.idCardNumber,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { DomainEvent } from './index';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
constructor(
|
||||||
super();
|
public readonly payload: { userId: string; phoneNumber: string },
|
||||||
}
|
) {
|
||||||
|
super();
|
||||||
get eventType(): string {
|
}
|
||||||
return 'PhoneNumberBound';
|
|
||||||
}
|
get eventType(): string {
|
||||||
}
|
return 'PhoneNumberBound';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
import { DomainEvent } from './domain-event.base';
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'PhoneNumberBound';
|
return 'PhoneNumberBound';
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateId(): string {
|
get aggregateId(): string {
|
||||||
return this.userId;
|
return this.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateType(): string {
|
get aggregateType(): string {
|
||||||
return 'UserAccount';
|
return 'UserAccount';
|
||||||
}
|
}
|
||||||
|
|
||||||
toPayload(): object {
|
toPayload(): object {
|
||||||
return {
|
return {
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
phoneNumber: this.phoneNumber,
|
phoneNumber: this.phoneNumber,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { DomainEvent } from './index';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class UserAccountCreatedEvent extends DomainEvent {
|
export class UserAccountCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly payload: {
|
public readonly payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: number;
|
accountSequence: number;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
inviterSequence: number | null;
|
inviterSequence: number | null;
|
||||||
province: string;
|
province: string;
|
||||||
city: string;
|
city: string;
|
||||||
registeredAt: Date;
|
registeredAt: Date;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'UserAccountCreated';
|
return 'UserAccountCreated';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from './user-account.repository.interface';
|
export * from './user-account.repository.interface';
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
import { UserId } from '@/domain/value-objects';
|
import { UserId } from '@/domain/value-objects';
|
||||||
|
|
||||||
export interface MpcKeyShareData {
|
export interface MpcKeyShareData {
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
partyIndex: number;
|
partyIndex: number;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
totalParties: number;
|
totalParties: number;
|
||||||
encryptedShareData: string;
|
encryptedShareData: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpcKeyShare {
|
export interface MpcKeyShare {
|
||||||
shareId: bigint;
|
shareId: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
partyIndex: number;
|
partyIndex: number;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
totalParties: number;
|
totalParties: number;
|
||||||
encryptedShareData: string;
|
encryptedShareData: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
rotatedAt: Date | null;
|
rotatedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
|
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
|
||||||
|
|
||||||
export interface MpcKeyShareRepository {
|
export interface MpcKeyShareRepository {
|
||||||
/**
|
/**
|
||||||
* 保存服务端 MPC 分片
|
* 保存服务端 MPC 分片
|
||||||
*/
|
*/
|
||||||
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
|
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户ID查找分片
|
* 根据用户ID查找分片
|
||||||
*/
|
*/
|
||||||
findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
|
findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据公钥查找分片
|
* 根据公钥查找分片
|
||||||
*/
|
*/
|
||||||
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
|
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新分片状态 (用于密钥轮换)
|
* 更新分片状态 (用于密钥轮换)
|
||||||
*/
|
*/
|
||||||
updateStatus(shareId: bigint, status: string): Promise<void>;
|
updateStatus(shareId: bigint, status: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮换分片 (更新分片数据)
|
* 轮换分片 (更新分片数据)
|
||||||
*/
|
*/
|
||||||
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
|
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,73 @@
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import {
|
import {
|
||||||
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus,
|
UserId,
|
||||||
} from '@/domain/value-objects';
|
AccountSequence,
|
||||||
|
PhoneNumber,
|
||||||
export interface Pagination {
|
ReferralCode,
|
||||||
page: number;
|
ChainType,
|
||||||
limit: number;
|
AccountStatus,
|
||||||
}
|
KYCStatus,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
export interface ReferralLinkData {
|
|
||||||
linkId: bigint;
|
export interface Pagination {
|
||||||
userId: bigint;
|
page: number;
|
||||||
referralCode: string;
|
limit: number;
|
||||||
shortCode: string;
|
}
|
||||||
channel: string | null;
|
|
||||||
campaignId: string | null;
|
export interface ReferralLinkData {
|
||||||
createdAt: Date;
|
linkId: bigint;
|
||||||
}
|
userId: bigint;
|
||||||
|
referralCode: string;
|
||||||
export interface CreateReferralLinkParams {
|
shortCode: string;
|
||||||
userId: bigint;
|
channel: string | null;
|
||||||
referralCode: string;
|
campaignId: string | null;
|
||||||
shortCode: string;
|
createdAt: Date;
|
||||||
channel: string | null;
|
}
|
||||||
campaignId: string | null;
|
|
||||||
}
|
export interface CreateReferralLinkParams {
|
||||||
|
userId: bigint;
|
||||||
export interface UserAccountRepository {
|
referralCode: string;
|
||||||
save(account: UserAccount): Promise<void>;
|
shortCode: string;
|
||||||
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
channel: string | null;
|
||||||
findById(userId: UserId): Promise<UserAccount | null>;
|
campaignId: string | null;
|
||||||
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
}
|
||||||
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
|
||||||
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
export interface UserAccountRepository {
|
||||||
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
save(account: UserAccount): Promise<void>;
|
||||||
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>;
|
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
||||||
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
findById(userId: UserId): Promise<UserAccount | null>;
|
||||||
getNextAccountSequence(): Promise<AccountSequence>;
|
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
||||||
findUsers(
|
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
||||||
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string },
|
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
||||||
pagination?: Pagination,
|
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
||||||
): Promise<UserAccount[]>;
|
findByWalletAddress(
|
||||||
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
// 推荐相关
|
): Promise<UserAccount | null>;
|
||||||
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>;
|
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
||||||
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>;
|
getNextAccountSequence(): Promise<AccountSequence>;
|
||||||
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
findUsers(
|
||||||
}
|
filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
kycStatus?: KYCStatus;
|
||||||
|
keyword?: string;
|
||||||
|
},
|
||||||
|
pagination?: Pagination,
|
||||||
|
): Promise<UserAccount[]>;
|
||||||
|
countUsers(filters?: {
|
||||||
|
status?: AccountStatus;
|
||||||
|
kycStatus?: KYCStatus;
|
||||||
|
}): Promise<number>;
|
||||||
|
|
||||||
|
// 推荐相关
|
||||||
|
findByInviterSequence(
|
||||||
|
inviterSequence: AccountSequence,
|
||||||
|
): Promise<UserAccount[]>;
|
||||||
|
createReferralLink(
|
||||||
|
params: CreateReferralLinkParams,
|
||||||
|
): Promise<ReferralLinkData>;
|
||||||
|
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { AccountSequence } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
@Injectable()
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
export class AccountSequenceGeneratorService {
|
import { AccountSequence } from '@/domain/value-objects';
|
||||||
constructor(
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Injectable()
|
||||||
private readonly repository: UserAccountRepository,
|
export class AccountSequenceGeneratorService {
|
||||||
) {}
|
constructor(
|
||||||
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
private readonly repository: UserAccountRepository,
|
||||||
return this.repository.getNextAccountSequence();
|
) {}
|
||||||
}
|
|
||||||
}
|
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||||
|
return this.repository.getNextAccountSequence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,87 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
// ============ ValidationResult ============
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
export class ValidationResult {
|
import {
|
||||||
private constructor(
|
AccountSequence,
|
||||||
public readonly isValid: boolean,
|
PhoneNumber,
|
||||||
public readonly errorMessage: string | null,
|
ReferralCode,
|
||||||
) {}
|
ChainType,
|
||||||
|
} from '@/domain/value-objects';
|
||||||
static success(): ValidationResult {
|
|
||||||
return new ValidationResult(true, null);
|
// ============ ValidationResult ============
|
||||||
}
|
export class ValidationResult {
|
||||||
|
private constructor(
|
||||||
static failure(message: string): ValidationResult {
|
public readonly isValid: boolean,
|
||||||
return new ValidationResult(false, message);
|
public readonly errorMessage: string | null,
|
||||||
}
|
) {}
|
||||||
}
|
|
||||||
|
static success(): ValidationResult {
|
||||||
// ============ AccountSequenceGeneratorService ============
|
return new ValidationResult(true, null);
|
||||||
@Injectable()
|
}
|
||||||
export class AccountSequenceGeneratorService {
|
|
||||||
constructor(
|
static failure(message: string): ValidationResult {
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
return new ValidationResult(false, message);
|
||||||
private readonly repository: UserAccountRepository,
|
}
|
||||||
) {}
|
}
|
||||||
|
|
||||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
// ============ AccountSequenceGeneratorService ============
|
||||||
return this.repository.getNextAccountSequence();
|
@Injectable()
|
||||||
}
|
export class AccountSequenceGeneratorService {
|
||||||
}
|
constructor(
|
||||||
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
// ============ UserValidatorService ============
|
private readonly repository: UserAccountRepository,
|
||||||
@Injectable()
|
) {}
|
||||||
export class UserValidatorService {
|
|
||||||
constructor(
|
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
return this.repository.getNextAccountSequence();
|
||||||
private readonly repository: UserAccountRepository,
|
}
|
||||||
) {}
|
}
|
||||||
|
|
||||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
// ============ UserValidatorService ============
|
||||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
@Injectable()
|
||||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
export class UserValidatorService {
|
||||||
return ValidationResult.success();
|
constructor(
|
||||||
}
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
|
private readonly repository: UserAccountRepository,
|
||||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
) {}
|
||||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
|
||||||
return ValidationResult.success();
|
async validatePhoneNumber(
|
||||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
phoneNumber: PhoneNumber,
|
||||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
): Promise<ValidationResult> {
|
||||||
// return ValidationResult.success();
|
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||||
}
|
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||||
|
return ValidationResult.success();
|
||||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
}
|
||||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
|
||||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
}
|
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||||
|
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
// return ValidationResult.success();
|
||||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
}
|
||||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
|
||||||
return ValidationResult.success();
|
async validateReferralCode(
|
||||||
}
|
referralCode: ReferralCode,
|
||||||
}
|
): Promise<ValidationResult> {
|
||||||
|
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||||
|
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||||
|
if (!inviter.isActive)
|
||||||
|
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||||
|
return ValidationResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateWalletAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const existing = await this.repository.findByWalletAddress(
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
);
|
||||||
|
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||||
|
return ValidationResult.success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,67 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import {
|
||||||
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
UserAccountRepository,
|
||||||
|
USER_ACCOUNT_REPOSITORY,
|
||||||
export class ValidationResult {
|
} from '@/domain/repositories/user-account.repository.interface';
|
||||||
private constructor(
|
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||||
public readonly isValid: boolean,
|
|
||||||
public readonly errorMessage: string | null,
|
export class ValidationResult {
|
||||||
) {}
|
private constructor(
|
||||||
|
public readonly isValid: boolean,
|
||||||
static success(): ValidationResult {
|
public readonly errorMessage: string | null,
|
||||||
return new ValidationResult(true, null);
|
) {}
|
||||||
}
|
|
||||||
|
static success(): ValidationResult {
|
||||||
static failure(message: string): ValidationResult {
|
return new ValidationResult(true, null);
|
||||||
return new ValidationResult(false, message);
|
}
|
||||||
}
|
|
||||||
}
|
static failure(message: string): ValidationResult {
|
||||||
|
return new ValidationResult(false, message);
|
||||||
@Injectable()
|
}
|
||||||
export class UserValidatorService {
|
}
|
||||||
constructor(
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Injectable()
|
||||||
private readonly repository: UserAccountRepository,
|
export class UserValidatorService {
|
||||||
) {}
|
constructor(
|
||||||
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
private readonly repository: UserAccountRepository,
|
||||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
) {}
|
||||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
|
||||||
return ValidationResult.success();
|
async validatePhoneNumber(
|
||||||
}
|
phoneNumber: PhoneNumber,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||||
return ValidationResult.success();
|
return ValidationResult.success();
|
||||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
}
|
||||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
|
||||||
// return ValidationResult.success();
|
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||||
}
|
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||||
|
return ValidationResult.success();
|
||||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
// return ValidationResult.success();
|
||||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
}
|
||||||
return ValidationResult.success();
|
|
||||||
}
|
async validateReferralCode(
|
||||||
|
referralCode: ReferralCode,
|
||||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
): Promise<ValidationResult> {
|
||||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||||
return ValidationResult.success();
|
if (!inviter.isActive)
|
||||||
}
|
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||||
}
|
return ValidationResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateWalletAddress(
|
||||||
|
chainType: ChainType,
|
||||||
|
address: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const existing = await this.repository.findByWalletAddress(
|
||||||
|
chainType,
|
||||||
|
address,
|
||||||
|
);
|
||||||
|
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||||
|
return ValidationResult.success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,60 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账户序列号值对象
|
* 账户序列号值对象
|
||||||
* 格式: D + 年(2位) + 月(2位) + 日(2位) + 5位序号
|
* 格式: D + 年(2位) + 月(2位) + 日(2位) + 5位序号
|
||||||
* 示例: D2512110008 -> 2025年12月11日的第8个注册用户
|
* 示例: D2512110008 -> 2025年12月11日的第8个注册用户
|
||||||
*/
|
*/
|
||||||
export class AccountSequence {
|
export class AccountSequence {
|
||||||
private static readonly PATTERN = /^D\d{11}$/;
|
private static readonly PATTERN = /^D\d{11}$/;
|
||||||
|
|
||||||
constructor(public readonly value: string) {
|
constructor(public readonly value: string) {
|
||||||
if (!AccountSequence.PATTERN.test(value)) {
|
if (!AccountSequence.PATTERN.test(value)) {
|
||||||
throw new DomainError(`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`);
|
throw new DomainError(
|
||||||
}
|
`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`,
|
||||||
}
|
);
|
||||||
|
}
|
||||||
static create(value: string): AccountSequence {
|
}
|
||||||
return new AccountSequence(value);
|
|
||||||
}
|
static create(value: string): AccountSequence {
|
||||||
|
return new AccountSequence(value);
|
||||||
/**
|
}
|
||||||
* 根据日期和当日序号生成新的账户序列号
|
|
||||||
* @param date 日期
|
/**
|
||||||
* @param dailySequence 当日序号 (0-99999)
|
* 根据日期和当日序号生成新的账户序列号
|
||||||
*/
|
* @param date 日期
|
||||||
static generate(date: Date, dailySequence: number): AccountSequence {
|
* @param dailySequence 当日序号 (0-99999)
|
||||||
if (dailySequence < 0 || dailySequence > 99999) {
|
*/
|
||||||
throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`);
|
static generate(date: Date, dailySequence: number): AccountSequence {
|
||||||
}
|
if (dailySequence < 0 || dailySequence > 99999) {
|
||||||
const year = String(date.getFullYear()).slice(-2);
|
throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`);
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
}
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const year = String(date.getFullYear()).slice(-2);
|
||||||
const seq = String(dailySequence).padStart(5, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
return new AccountSequence(`D${year}${month}${day}${seq}`);
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
}
|
const seq = String(dailySequence).padStart(5, '0');
|
||||||
|
return new AccountSequence(`D${year}${month}${day}${seq}`);
|
||||||
/**
|
}
|
||||||
* 从序列号中提取日期字符串 (YYMMDD)
|
|
||||||
*/
|
/**
|
||||||
get dateString(): string {
|
* 从序列号中提取日期字符串 (YYMMDD)
|
||||||
return this.value.slice(1, 7);
|
*/
|
||||||
}
|
get dateString(): string {
|
||||||
|
return this.value.slice(1, 7);
|
||||||
/**
|
}
|
||||||
* 从序列号中提取当日序号
|
|
||||||
*/
|
/**
|
||||||
get dailySequence(): number {
|
* 从序列号中提取当日序号
|
||||||
return parseInt(this.value.slice(7), 10);
|
*/
|
||||||
}
|
get dailySequence(): number {
|
||||||
|
return parseInt(this.value.slice(7), 10);
|
||||||
equals(other: AccountSequence): boolean {
|
}
|
||||||
return this.value === other.value;
|
|
||||||
}
|
equals(other: AccountSequence): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
toString(): string {
|
}
|
||||||
return this.value;
|
|
||||||
}
|
toString(): string {
|
||||||
}
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
export class DeviceInfo {
|
export class DeviceInfo {
|
||||||
private _lastActiveAt: Date;
|
private _lastActiveAt: Date;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
public readonly deviceName: string,
|
public readonly deviceName: string,
|
||||||
public readonly addedAt: Date,
|
public readonly addedAt: Date,
|
||||||
lastActiveAt: Date,
|
lastActiveAt: Date,
|
||||||
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
||||||
) {
|
) {
|
||||||
this._lastActiveAt = lastActiveAt;
|
this._lastActiveAt = lastActiveAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastActiveAt(): Date {
|
get lastActiveAt(): Date {
|
||||||
return this._lastActiveAt;
|
return this._lastActiveAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActivity(): void {
|
updateActivity(): void {
|
||||||
this._lastActiveAt = new Date();
|
this._lastActiveAt = new Date();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
import {
|
||||||
|
createHash,
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
} from 'crypto';
|
||||||
import * as bip39 from '@scure/bip39';
|
import * as bip39 from '@scure/bip39';
|
||||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
|
|
||||||
|
|
@ -144,7 +150,9 @@ export class DeviceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
get deviceModel(): string | undefined {
|
get deviceModel(): string | undefined {
|
||||||
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined;
|
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get osVersion(): string | undefined {
|
get osVersion(): string | undefined {
|
||||||
|
|
@ -188,13 +196,27 @@ export class KYCInfo {
|
||||||
if (!realName || realName.length < 2) {
|
if (!realName || realName.length < 2) {
|
||||||
throw new DomainError('真实姓名不合法');
|
throw new DomainError('真实姓名不合法');
|
||||||
}
|
}
|
||||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
if (
|
||||||
|
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
|
idCardNumber,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new DomainError('身份证号格式错误');
|
throw new DomainError('身份证号格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
static create(params: {
|
||||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
realName: string;
|
||||||
|
idCardNumber: string;
|
||||||
|
idCardFrontUrl: string;
|
||||||
|
idCardBackUrl: string;
|
||||||
|
}): KYCInfo {
|
||||||
|
return new KYCInfo(
|
||||||
|
params.realName,
|
||||||
|
params.idCardNumber,
|
||||||
|
params.idCardFrontUrl,
|
||||||
|
params.idCardBackUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedIdCardNumber(): string {
|
maskedIdCardNumber(): string {
|
||||||
|
|
@ -255,7 +277,11 @@ export class MnemonicEncryption {
|
||||||
static decrypt(encryptedData: string, key: string): string {
|
static decrypt(encryptedData: string, key: string): string {
|
||||||
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
|
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
|
||||||
const derivedKey = this.deriveKey(key);
|
const derivedKey = this.deriveKey(key);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex'));
|
const decipher = createDecipheriv(
|
||||||
|
'aes-256-gcm',
|
||||||
|
derivedKey,
|
||||||
|
Buffer.from(iv, 'hex'),
|
||||||
|
);
|
||||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,39 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class KYCInfo {
|
export class KYCInfo {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly realName: string,
|
public readonly realName: string,
|
||||||
public readonly idCardNumber: string,
|
public readonly idCardNumber: string,
|
||||||
public readonly idCardFrontUrl: string,
|
public readonly idCardFrontUrl: string,
|
||||||
public readonly idCardBackUrl: string,
|
public readonly idCardBackUrl: string,
|
||||||
) {
|
) {
|
||||||
if (!realName || realName.length < 2) {
|
if (!realName || realName.length < 2) {
|
||||||
throw new DomainError('真实姓名不合法');
|
throw new DomainError('真实姓名不合法');
|
||||||
}
|
}
|
||||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
if (
|
||||||
throw new DomainError('身份证号格式错误');
|
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||||
}
|
idCardNumber,
|
||||||
}
|
)
|
||||||
|
) {
|
||||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
throw new DomainError('身份证号格式错误');
|
||||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedIdCardNumber(): string {
|
static create(params: {
|
||||||
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
realName: string;
|
||||||
}
|
idCardNumber: string;
|
||||||
}
|
idCardFrontUrl: string;
|
||||||
|
idCardBackUrl: string;
|
||||||
|
}): KYCInfo {
|
||||||
|
return new KYCInfo(
|
||||||
|
params.realName,
|
||||||
|
params.idCardNumber,
|
||||||
|
params.idCardFrontUrl,
|
||||||
|
params.idCardBackUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
maskedIdCardNumber(): string {
|
||||||
|
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,118 @@
|
||||||
import { Mnemonic } from './index';
|
import { Mnemonic } from './index';
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
describe('Mnemonic ValueObject', () => {
|
describe('Mnemonic ValueObject', () => {
|
||||||
describe('generate', () => {
|
describe('generate', () => {
|
||||||
it('应该生成有效的12个单词助记词', () => {
|
it('应该生成有效的12个单词助记词', () => {
|
||||||
const mnemonic = Mnemonic.generate();
|
const mnemonic = Mnemonic.generate();
|
||||||
|
|
||||||
expect(mnemonic).toBeDefined();
|
expect(mnemonic).toBeDefined();
|
||||||
expect(mnemonic.value).toBeDefined();
|
expect(mnemonic.value).toBeDefined();
|
||||||
|
|
||||||
const words = mnemonic.getWords();
|
const words = mnemonic.getWords();
|
||||||
expect(words).toHaveLength(12);
|
expect(words).toHaveLength(12);
|
||||||
expect(words.every(word => word.length > 0)).toBe(true);
|
expect(words.every((word) => word.length > 0)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('生成的助记词应该能转换为 seed', () => {
|
it('生成的助记词应该能转换为 seed', () => {
|
||||||
const mnemonic = Mnemonic.generate();
|
const mnemonic = Mnemonic.generate();
|
||||||
const seed = mnemonic.toSeed();
|
const seed = mnemonic.toSeed();
|
||||||
|
|
||||||
expect(seed).toBeDefined();
|
expect(seed).toBeDefined();
|
||||||
expect(seed).toBeInstanceOf(Uint8Array);
|
expect(seed).toBeInstanceOf(Uint8Array);
|
||||||
expect(seed.length).toBeGreaterThan(0);
|
expect(seed.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('每次生成的助记词应该不同', () => {
|
it('每次生成的助记词应该不同', () => {
|
||||||
const mnemonic1 = Mnemonic.generate();
|
const mnemonic1 = Mnemonic.generate();
|
||||||
const mnemonic2 = Mnemonic.generate();
|
const mnemonic2 = Mnemonic.generate();
|
||||||
|
|
||||||
expect(mnemonic1.value).not.toBe(mnemonic2.value);
|
expect(mnemonic1.value).not.toBe(mnemonic2.value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('应该接受有效的助记词字符串', () => {
|
it('应该接受有效的助记词字符串', () => {
|
||||||
const validMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
const validMnemonic =
|
||||||
const mnemonic = Mnemonic.create(validMnemonic);
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
const mnemonic = Mnemonic.create(validMnemonic);
|
||||||
expect(mnemonic.value).toBe(validMnemonic);
|
|
||||||
});
|
expect(mnemonic.value).toBe(validMnemonic);
|
||||||
|
});
|
||||||
it('应该拒绝无效的助记词', () => {
|
|
||||||
const invalidMnemonic = 'invalid invalid invalid';
|
it('应该拒绝无效的助记词', () => {
|
||||||
|
const invalidMnemonic = 'invalid invalid invalid';
|
||||||
expect(() => {
|
|
||||||
Mnemonic.create(invalidMnemonic);
|
expect(() => {
|
||||||
}).toThrow(DomainError);
|
Mnemonic.create(invalidMnemonic);
|
||||||
});
|
}).toThrow(DomainError);
|
||||||
|
});
|
||||||
it('应该拒绝空字符串', () => {
|
|
||||||
expect(() => {
|
it('应该拒绝空字符串', () => {
|
||||||
Mnemonic.create('');
|
expect(() => {
|
||||||
}).toThrow(DomainError);
|
Mnemonic.create('');
|
||||||
});
|
}).toThrow(DomainError);
|
||||||
|
});
|
||||||
it('应该拒绝非英文单词', () => {
|
|
||||||
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
it('应该拒绝非英文单词', () => {
|
||||||
|
const invalidMnemonic =
|
||||||
expect(() => {
|
'中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
||||||
Mnemonic.create(invalidMnemonic);
|
|
||||||
}).toThrow(DomainError);
|
expect(() => {
|
||||||
});
|
Mnemonic.create(invalidMnemonic);
|
||||||
});
|
}).toThrow(DomainError);
|
||||||
|
});
|
||||||
describe('getWords', () => {
|
});
|
||||||
it('应该返回单词数组', () => {
|
|
||||||
const mnemonic = Mnemonic.generate();
|
describe('getWords', () => {
|
||||||
const words = mnemonic.getWords();
|
it('应该返回单词数组', () => {
|
||||||
|
const mnemonic = Mnemonic.generate();
|
||||||
expect(Array.isArray(words)).toBe(true);
|
const words = mnemonic.getWords();
|
||||||
expect(words.length).toBe(12);
|
|
||||||
});
|
expect(Array.isArray(words)).toBe(true);
|
||||||
});
|
expect(words.length).toBe(12);
|
||||||
|
});
|
||||||
describe('toSeed', () => {
|
});
|
||||||
it('相同的助记词应该生成相同的 seed', () => {
|
|
||||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
describe('toSeed', () => {
|
||||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
it('相同的助记词应该生成相同的 seed', () => {
|
||||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
const mnemonicStr =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
const seed1 = mnemonic1.toSeed();
|
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||||
const seed2 = mnemonic2.toSeed();
|
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||||
|
|
||||||
expect(seed1).toEqual(seed2);
|
const seed1 = mnemonic1.toSeed();
|
||||||
});
|
const seed2 = mnemonic2.toSeed();
|
||||||
|
|
||||||
it('不同的助记词应该生成不同的 seed', () => {
|
expect(seed1).toEqual(seed2);
|
||||||
const mnemonic1 = Mnemonic.generate();
|
});
|
||||||
const mnemonic2 = Mnemonic.generate();
|
|
||||||
|
it('不同的助记词应该生成不同的 seed', () => {
|
||||||
const seed1 = mnemonic1.toSeed();
|
const mnemonic1 = Mnemonic.generate();
|
||||||
const seed2 = mnemonic2.toSeed();
|
const mnemonic2 = Mnemonic.generate();
|
||||||
|
|
||||||
expect(seed1).not.toEqual(seed2);
|
const seed1 = mnemonic1.toSeed();
|
||||||
});
|
const seed2 = mnemonic2.toSeed();
|
||||||
});
|
|
||||||
|
expect(seed1).not.toEqual(seed2);
|
||||||
describe('equals', () => {
|
});
|
||||||
it('相同的助记词应该相等', () => {
|
});
|
||||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
||||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
describe('equals', () => {
|
||||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
it('相同的助记词应该相等', () => {
|
||||||
|
const mnemonicStr =
|
||||||
expect(mnemonic1.equals(mnemonic2)).toBe(true);
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
});
|
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||||
|
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||||
it('不同的助记词应该不相等', () => {
|
|
||||||
const mnemonic1 = Mnemonic.generate();
|
expect(mnemonic1.equals(mnemonic2)).toBe(true);
|
||||||
const mnemonic2 = Mnemonic.generate();
|
});
|
||||||
|
|
||||||
expect(mnemonic1.equals(mnemonic2)).toBe(false);
|
it('不同的助记词应该不相等', () => {
|
||||||
});
|
const mnemonic1 = Mnemonic.generate();
|
||||||
});
|
const mnemonic2 = Mnemonic.generate();
|
||||||
});
|
|
||||||
|
expect(mnemonic1.equals(mnemonic2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import * as bip39 from '@scure/bip39';
|
import * as bip39 from '@scure/bip39';
|
||||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
|
|
||||||
export class Mnemonic {
|
export class Mnemonic {
|
||||||
constructor(public readonly value: string) {
|
constructor(public readonly value: string) {
|
||||||
if (!bip39.validateMnemonic(value, wordlist)) {
|
if (!bip39.validateMnemonic(value, wordlist)) {
|
||||||
throw new DomainError('助记词格式错误');
|
throw new DomainError('助记词格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static generate(): Mnemonic {
|
static generate(): Mnemonic {
|
||||||
const mnemonic = bip39.generateMnemonic(wordlist, 128);
|
const mnemonic = bip39.generateMnemonic(wordlist, 128);
|
||||||
return new Mnemonic(mnemonic);
|
return new Mnemonic(mnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: string): Mnemonic {
|
static create(value: string): Mnemonic {
|
||||||
return new Mnemonic(value);
|
return new Mnemonic(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
toSeed(): Uint8Array {
|
toSeed(): Uint8Array {
|
||||||
return bip39.mnemonicToSeedSync(this.value);
|
return bip39.mnemonicToSeedSync(this.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWords(): string[] {
|
getWords(): string[] {
|
||||||
return this.value.split(' ');
|
return this.value.split(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Mnemonic): boolean {
|
equals(other: Mnemonic): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,90 @@
|
||||||
import { PhoneNumber } from './index';
|
import { PhoneNumber } from './index';
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
describe('PhoneNumber ValueObject', () => {
|
describe('PhoneNumber ValueObject', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('应该接受有效的中国手机号', () => {
|
it('应该接受有效的中国手机号', () => {
|
||||||
const validPhones = [
|
const validPhones = [
|
||||||
'13800138000',
|
'13800138000',
|
||||||
'13912345678',
|
'13912345678',
|
||||||
'15800001111',
|
'15800001111',
|
||||||
'18600002222',
|
'18600002222',
|
||||||
'19900003333',
|
'19900003333',
|
||||||
];
|
];
|
||||||
|
|
||||||
validPhones.forEach(phone => {
|
validPhones.forEach((phone) => {
|
||||||
const phoneNumber = PhoneNumber.create(phone);
|
const phoneNumber = PhoneNumber.create(phone);
|
||||||
expect(phoneNumber.value).toBe(phone);
|
expect(phoneNumber.value).toBe(phone);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该拒绝无效的手机号格式', () => {
|
it('应该拒绝无效的手机号格式', () => {
|
||||||
const invalidPhones = [
|
const invalidPhones = [
|
||||||
'12800138000', // 不是1开头
|
'12800138000', // 不是1开头
|
||||||
'1380013800', // 少于11位
|
'1380013800', // 少于11位
|
||||||
'138001380000', // 多于11位
|
'138001380000', // 多于11位
|
||||||
'10800138000', // 第二位不是3-9
|
'10800138000', // 第二位不是3-9
|
||||||
'abcdefghijk', // 非数字
|
'abcdefghijk', // 非数字
|
||||||
'', // 空字符串
|
'', // 空字符串
|
||||||
];
|
];
|
||||||
|
|
||||||
invalidPhones.forEach(phone => {
|
invalidPhones.forEach((phone) => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
PhoneNumber.create(phone);
|
PhoneNumber.create(phone);
|
||||||
}).toThrow(DomainError);
|
}).toThrow(DomainError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该拒绝包含特殊字符的手机号', () => {
|
it('应该拒绝包含特殊字符的手机号', () => {
|
||||||
const invalidPhones = [
|
const invalidPhones = [
|
||||||
'138-0013-8000',
|
'138-0013-8000',
|
||||||
'138 0013 8000',
|
'138 0013 8000',
|
||||||
'+8613800138000',
|
'+8613800138000',
|
||||||
];
|
];
|
||||||
|
|
||||||
invalidPhones.forEach(phone => {
|
invalidPhones.forEach((phone) => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
PhoneNumber.create(phone);
|
PhoneNumber.create(phone);
|
||||||
}).toThrow(DomainError);
|
}).toThrow(DomainError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('masked', () => {
|
describe('masked', () => {
|
||||||
it('应该正确掩码手机号', () => {
|
it('应该正确掩码手机号', () => {
|
||||||
const phoneNumber = PhoneNumber.create('13800138000');
|
const phoneNumber = PhoneNumber.create('13800138000');
|
||||||
const masked = phoneNumber.masked();
|
const masked = phoneNumber.masked();
|
||||||
|
|
||||||
expect(masked).toBe('138****8000');
|
expect(masked).toBe('138****8000');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('掩码后应该隐藏中间4位', () => {
|
it('掩码后应该隐藏中间4位', () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
{ input: '13912345678', expected: '139****5678' },
|
{ input: '13912345678', expected: '139****5678' },
|
||||||
{ input: '15800001111', expected: '158****1111' },
|
{ input: '15800001111', expected: '158****1111' },
|
||||||
{ input: '18600002222', expected: '186****2222' },
|
{ input: '18600002222', expected: '186****2222' },
|
||||||
];
|
];
|
||||||
|
|
||||||
testCases.forEach(({ input, expected }) => {
|
testCases.forEach(({ input, expected }) => {
|
||||||
const phoneNumber = PhoneNumber.create(input);
|
const phoneNumber = PhoneNumber.create(input);
|
||||||
expect(phoneNumber.masked()).toBe(expected);
|
expect(phoneNumber.masked()).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('equals', () => {
|
describe('equals', () => {
|
||||||
it('相同的手机号应该相等', () => {
|
it('相同的手机号应该相等', () => {
|
||||||
const phone1 = PhoneNumber.create('13800138000');
|
const phone1 = PhoneNumber.create('13800138000');
|
||||||
const phone2 = PhoneNumber.create('13800138000');
|
const phone2 = PhoneNumber.create('13800138000');
|
||||||
|
|
||||||
expect(phone1.equals(phone2)).toBe(true);
|
expect(phone1.equals(phone2)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('不同的手机号应该不相等', () => {
|
it('不同的手机号应该不相等', () => {
|
||||||
const phone1 = PhoneNumber.create('13800138000');
|
const phone1 = PhoneNumber.create('13800138000');
|
||||||
const phone2 = PhoneNumber.create('13912345678');
|
const phone2 = PhoneNumber.create('13912345678');
|
||||||
|
|
||||||
expect(phone1.equals(phone2)).toBe(false);
|
expect(phone1.equals(phone2)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class PhoneNumber {
|
export class PhoneNumber {
|
||||||
constructor(public readonly value: string) {
|
constructor(public readonly value: string) {
|
||||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||||
throw new DomainError('手机号格式错误');
|
throw new DomainError('手机号格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: string): PhoneNumber {
|
static create(value: string): PhoneNumber {
|
||||||
return new PhoneNumber(value);
|
return new PhoneNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: PhoneNumber): boolean {
|
equals(other: PhoneNumber): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
masked(): string {
|
masked(): string {
|
||||||
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class ReferralCode {
|
export class ReferralCode {
|
||||||
constructor(public readonly value: string) {
|
constructor(public readonly value: string) {
|
||||||
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
|
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
|
||||||
if (!/^[A-Z0-9]{6,20}$/.test(value)) {
|
if (!/^[A-Z0-9]{6,20}$/.test(value)) {
|
||||||
throw new DomainError('推荐码格式错误');
|
throw new DomainError('推荐码格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static generate(): ReferralCode {
|
static generate(): ReferralCode {
|
||||||
// 生成6位随机推荐码(identity-service 本地生成)
|
// 生成6位随机推荐码(identity-service 本地生成)
|
||||||
// 注:referral-service 会生成10位的推荐码,两者都兼容
|
// 注:referral-service 会生成10位的推荐码,两者都兼容
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
let code = '';
|
let code = '';
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
}
|
}
|
||||||
return new ReferralCode(code);
|
return new ReferralCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: string): ReferralCode {
|
static create(value: string): ReferralCode {
|
||||||
return new ReferralCode(value.toUpperCase());
|
return new ReferralCode(value.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: ReferralCode): boolean {
|
equals(other: ReferralCode): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export interface VerifyMnemonicResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyMnemonicByAccountParams {
|
export interface VerifyMnemonicByAccountParams {
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,8 +60,12 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 验证助记词是否匹配指定的钱包地址
|
* 验证助记词是否匹配指定的钱包地址
|
||||||
*/
|
*/
|
||||||
async verifyMnemonic(params: VerifyMnemonicParams): Promise<VerifyMnemonicResult> {
|
async verifyMnemonic(
|
||||||
this.logger.log(`Verifying mnemonic against ${params.expectedAddresses.length} addresses`);
|
params: VerifyMnemonicParams,
|
||||||
|
): Promise<VerifyMnemonicResult> {
|
||||||
|
this.logger.log(
|
||||||
|
`Verifying mnemonic against ${params.expectedAddresses.length} addresses`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
|
|
@ -78,7 +82,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
this.logger.log(
|
||||||
|
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to verify mnemonic', error);
|
this.logger.error('Failed to verify mnemonic', error);
|
||||||
|
|
@ -89,7 +95,9 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 通过账户序列号验证助记词(用于账户恢复)
|
* 通过账户序列号验证助记词(用于账户恢复)
|
||||||
*/
|
*/
|
||||||
async verifyMnemonicByAccount(params: VerifyMnemonicByAccountParams): Promise<VerifyMnemonicHashResult> {
|
async verifyMnemonicByAccount(
|
||||||
|
params: VerifyMnemonicByAccountParams,
|
||||||
|
): Promise<VerifyMnemonicHashResult> {
|
||||||
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
|
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,7 +115,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
this.logger.log(
|
||||||
|
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to verify mnemonic', error);
|
this.logger.error('Failed to verify mnemonic', error);
|
||||||
|
|
@ -133,7 +143,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Derived ${response.data.addresses.length} addresses from mnemonic`);
|
this.logger.log(
|
||||||
|
`Derived ${response.data.addresses.length} addresses from mnemonic`,
|
||||||
|
);
|
||||||
return response.data.addresses;
|
return response.data.addresses;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to derive addresses from mnemonic', error);
|
this.logger.error('Failed to derive addresses from mnemonic', error);
|
||||||
|
|
@ -145,7 +157,9 @@ export class BlockchainClientService {
|
||||||
* 标记助记词已备份
|
* 标记助记词已备份
|
||||||
*/
|
*/
|
||||||
async markMnemonicBackedUp(accountSequence: string): Promise<void> {
|
async markMnemonicBackedUp(accountSequence: string): Promise<void> {
|
||||||
this.logger.log(`Marking mnemonic as backed up for account ${accountSequence}`);
|
this.logger.log(
|
||||||
|
`Marking mnemonic as backed up for account ${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(
|
await firstValueFrom(
|
||||||
|
|
@ -159,7 +173,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic marked as backed up for account ${accountSequence}`);
|
this.logger.log(
|
||||||
|
`Mnemonic marked as backed up for account ${accountSequence}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to mark mnemonic as backed up', error);
|
this.logger.error('Failed to mark mnemonic as backed up', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -169,8 +185,13 @@ export class BlockchainClientService {
|
||||||
/**
|
/**
|
||||||
* 挂失助记词
|
* 挂失助记词
|
||||||
*/
|
*/
|
||||||
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> {
|
async revokeMnemonic(
|
||||||
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`);
|
accountSequence: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
this.logger.log(
|
||||||
|
`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
|
|
@ -184,7 +205,9 @@ export class BlockchainClientService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Mnemonic revoke result: success=${response.data.success}`);
|
this.logger.log(
|
||||||
|
`Mnemonic revoke result: success=${response.data.success}`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to revoke mnemonic', error);
|
this.logger.error('Failed to revoke mnemonic', error);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export * from './mpc.module';
|
export * from './mpc.module';
|
||||||
export * from './mpc-client.service';
|
export * from './mpc-client.service';
|
||||||
export * from './mpc-wallet.service';
|
export * from './mpc-wallet.service';
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,250 +1,286 @@
|
||||||
/**
|
/**
|
||||||
* MPC Wallet Service
|
* MPC Wallet Service
|
||||||
*
|
*
|
||||||
* 使用 MPC 2-of-3 协议生成三链钱包地址
|
* 使用 MPC 2-of-3 协议生成三链钱包地址
|
||||||
* 并对地址进行签名验证
|
* 并对地址进行签名验证
|
||||||
*
|
*
|
||||||
* 调用路径: identity-service → mpc-service → mpc-system
|
* 调用路径: identity-service → mpc-service → mpc-system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { MpcClientService } from './mpc-client.service';
|
import { MpcClientService } from './mpc-client.service';
|
||||||
|
|
||||||
export interface MpcWalletGenerationParams {
|
export interface MpcWalletGenerationParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string; // 用户名 (用于 MPC keygen)
|
username: string; // 用户名 (用于 MPC keygen)
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChainWalletInfo {
|
export interface ChainWalletInfo {
|
||||||
chainType: 'KAVA' | 'DST' | 'BSC';
|
chainType: 'KAVA' | 'DST' | 'BSC';
|
||||||
address: string;
|
address: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
addressDigest: string;
|
addressDigest: string;
|
||||||
signature: string; // 64 bytes hex (R + S)
|
signature: string; // 64 bytes hex (R + S)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpcWalletGenerationResult {
|
export interface MpcWalletGenerationResult {
|
||||||
publicKey: string; // MPC 公钥
|
publicKey: string; // MPC 公钥
|
||||||
delegateShare: string; // delegate share (加密的用户分片)
|
delegateShare: string; // delegate share (加密的用户分片)
|
||||||
serverParties: string[]; // 服务器 party IDs
|
serverParties: string[]; // 服务器 party IDs
|
||||||
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
||||||
sessionId: string; // MPC 会话ID
|
sessionId: string; // MPC 会话ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MpcWalletService {
|
export class MpcWalletService {
|
||||||
private readonly logger = new Logger(MpcWalletService.name);
|
private readonly logger = new Logger(MpcWalletService.name);
|
||||||
|
|
||||||
// 三条链的地址生成配置
|
// 三条链的地址生成配置
|
||||||
private readonly chainConfigs = {
|
private readonly chainConfigs = {
|
||||||
BSC: {
|
BSC: {
|
||||||
name: 'Binance Smart Chain',
|
name: 'Binance Smart Chain',
|
||||||
prefix: '0x',
|
prefix: '0x',
|
||||||
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
|
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
|
||||||
addressType: 'evm' as const,
|
addressType: 'evm' as const,
|
||||||
},
|
},
|
||||||
KAVA: {
|
KAVA: {
|
||||||
name: 'Kava EVM',
|
name: 'Kava EVM',
|
||||||
prefix: '0x',
|
prefix: '0x',
|
||||||
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
|
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
|
||||||
addressType: 'evm' as const,
|
addressType: 'evm' as const,
|
||||||
},
|
},
|
||||||
DST: {
|
DST: {
|
||||||
name: 'Durian Star Token',
|
name: 'Durian Star Token',
|
||||||
prefix: 'dst', // Cosmos Bech32 前缀
|
prefix: 'dst', // Cosmos Bech32 前缀
|
||||||
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
||||||
addressType: 'cosmos' as const,
|
addressType: 'cosmos' as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly mpcClient: MpcClientService) {}
|
||||||
private readonly mpcClient: MpcClientService,
|
|
||||||
) {}
|
/**
|
||||||
|
* 使用 MPC 2-of-3 生成三链钱包
|
||||||
/**
|
*
|
||||||
* 使用 MPC 2-of-3 生成三链钱包
|
* 流程:
|
||||||
*
|
* 1. 生成 MPC 密钥 (2-of-3)
|
||||||
* 流程:
|
* 2. 从公钥派生三条链的地址
|
||||||
* 1. 生成 MPC 密钥 (2-of-3)
|
* 3. 计算地址摘要
|
||||||
* 2. 从公钥派生三条链的地址
|
* 4. 使用 MPC 签名对摘要进行签名
|
||||||
* 3. 计算地址摘要
|
* 5. 返回完整的钱包信息
|
||||||
* 4. 使用 MPC 签名对摘要进行签名
|
*/
|
||||||
* 5. 返回完整的钱包信息
|
async generateMpcWallet(
|
||||||
*/
|
params: MpcWalletGenerationParams,
|
||||||
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> {
|
): Promise<MpcWalletGenerationResult> {
|
||||||
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`);
|
this.logger.log(
|
||||||
|
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
|
||||||
// Step 1: 生成 MPC 密钥
|
);
|
||||||
const keygenResult = await this.mpcClient.executeKeygen({
|
|
||||||
sessionId: this.mpcClient.generateSessionId(),
|
// Step 1: 生成 MPC 密钥
|
||||||
username: params.username,
|
const keygenResult = await this.mpcClient.executeKeygen({
|
||||||
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
sessionId: this.mpcClient.generateSessionId(),
|
||||||
totalParties: 3,
|
username: params.username,
|
||||||
requireDelegate: true,
|
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
||||||
});
|
totalParties: 3,
|
||||||
|
requireDelegate: true,
|
||||||
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`);
|
});
|
||||||
|
|
||||||
// Step 2: 从公钥派生三条链的地址
|
this.logger.log(
|
||||||
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey);
|
`MPC keygen completed: publicKey=${keygenResult.publicKey}`,
|
||||||
|
);
|
||||||
// Step 3: 计算地址摘要
|
|
||||||
const addressDigest = this.computeAddressDigest(walletAddresses);
|
// Step 2: 从公钥派生三条链的地址
|
||||||
|
const walletAddresses = await this.deriveChainAddresses(
|
||||||
// Step 4: 使用 MPC 签名对摘要进行签名
|
keygenResult.publicKey,
|
||||||
const signingResult = await this.mpcClient.executeSigning({
|
);
|
||||||
username: params.username,
|
|
||||||
messageHash: addressDigest,
|
// Step 3: 计算地址摘要
|
||||||
});
|
const addressDigest = this.computeAddressDigest(walletAddresses);
|
||||||
|
|
||||||
this.logger.log(`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`);
|
// Step 4: 使用 MPC 签名对摘要进行签名
|
||||||
|
const signingResult = await this.mpcClient.executeSigning({
|
||||||
// Step 5: 构建钱包信息
|
username: params.username,
|
||||||
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
messageHash: addressDigest,
|
||||||
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
|
});
|
||||||
address: wa.address,
|
|
||||||
publicKey: keygenResult.publicKey,
|
this.logger.log(
|
||||||
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
|
`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`,
|
||||||
signature: signingResult.signature,
|
);
|
||||||
}));
|
|
||||||
|
// Step 5: 构建钱包信息
|
||||||
return {
|
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
||||||
publicKey: keygenResult.publicKey,
|
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
|
||||||
delegateShare: keygenResult.delegateShare.encryptedShare,
|
address: wa.address,
|
||||||
serverParties: keygenResult.serverParties,
|
publicKey: keygenResult.publicKey,
|
||||||
wallets,
|
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
|
||||||
sessionId: keygenResult.sessionId,
|
signature: signingResult.signature,
|
||||||
};
|
}));
|
||||||
}
|
|
||||||
|
return {
|
||||||
/**
|
publicKey: keygenResult.publicKey,
|
||||||
* 验证钱包地址签名
|
delegateShare: keygenResult.delegateShare.encryptedShare,
|
||||||
*
|
serverParties: keygenResult.serverParties,
|
||||||
* 用于检测地址是否被篡改
|
wallets,
|
||||||
*
|
sessionId: keygenResult.sessionId,
|
||||||
* @param address 钱包地址
|
};
|
||||||
* @param chainType 链类型
|
}
|
||||||
* @param publicKey 公钥 (hex)
|
|
||||||
* @param signature 签名 (64 bytes hex: R + S)
|
/**
|
||||||
*/
|
* 验证钱包地址签名
|
||||||
async verifyWalletSignature(
|
*
|
||||||
address: string,
|
* 用于检测地址是否被篡改
|
||||||
chainType: string,
|
*
|
||||||
publicKey: string,
|
* @param address 钱包地址
|
||||||
signature: string,
|
* @param chainType 链类型
|
||||||
): Promise<boolean> {
|
* @param publicKey 公钥 (hex)
|
||||||
try {
|
* @param signature 签名 (64 bytes hex: R + S)
|
||||||
const { ethers } = await import('ethers');
|
*/
|
||||||
|
async verifyWalletSignature(
|
||||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
address: string,
|
||||||
if (signature.length !== 128) {
|
chainType: string,
|
||||||
this.logger.error(`Invalid signature length: ${signature.length}, expected 128`);
|
publicKey: string,
|
||||||
return false;
|
signature: string,
|
||||||
}
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
const r = '0x' + signature.slice(0, 64);
|
const { ethers } = await import('ethers');
|
||||||
const s = '0x' + signature.slice(64, 128);
|
|
||||||
|
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
||||||
// 计算地址摘要
|
if (signature.length !== 128) {
|
||||||
const digest = this.computeSingleAddressDigest(address, chainType);
|
this.logger.error(
|
||||||
const digestBytes = Buffer.from(digest, 'hex');
|
`Invalid signature length: ${signature.length}, expected 128`,
|
||||||
|
);
|
||||||
// 尝试两种 recovery id
|
return false;
|
||||||
for (const v of [27, 28]) {
|
}
|
||||||
try {
|
|
||||||
const sig = ethers.Signature.from({ r, s, v });
|
const r = '0x' + signature.slice(0, 64);
|
||||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
const s = '0x' + signature.slice(64, 128);
|
||||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
|
||||||
|
// 计算地址摘要
|
||||||
if (compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase()) {
|
const digest = this.computeSingleAddressDigest(address, chainType);
|
||||||
return true;
|
const digestBytes = Buffer.from(digest, 'hex');
|
||||||
}
|
|
||||||
} catch {
|
// 尝试两种 recovery id
|
||||||
// 尝试下一个 v 值
|
for (const v of [27, 28]) {
|
||||||
}
|
try {
|
||||||
}
|
const sig = ethers.Signature.from({ r, s, v });
|
||||||
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||||
return false;
|
digestBytes,
|
||||||
} catch (error) {
|
sig,
|
||||||
this.logger.error(`Signature verification failed: ${error.message}`);
|
);
|
||||||
return false;
|
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||||
}
|
recoveredPubKey,
|
||||||
}
|
true,
|
||||||
|
);
|
||||||
/**
|
|
||||||
* 从 MPC 公钥派生三条链的地址
|
if (
|
||||||
*
|
compressedRecovered.slice(2).toLowerCase() ===
|
||||||
* - BSC/KAVA: EVM 地址 (keccak256)
|
publicKey.toLowerCase()
|
||||||
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
) {
|
||||||
*/
|
return true;
|
||||||
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> {
|
}
|
||||||
const { ethers } = await import('ethers');
|
} catch {
|
||||||
const { bech32 } = await import('bech32');
|
// 尝试下一个 v 值
|
||||||
|
}
|
||||||
// MPC 公钥 (压缩格式,33 bytes)
|
}
|
||||||
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
|
||||||
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex');
|
return false;
|
||||||
|
} catch (error) {
|
||||||
// 解压公钥 (如果是压缩格式)
|
this.logger.error(`Signature verification failed: ${error.message}`);
|
||||||
let uncompressedPubKey: string;
|
return false;
|
||||||
if (pubKeyHex.length === 68) {
|
}
|
||||||
// 压缩格式 (33 bytes = 66 hex chars + 0x)
|
}
|
||||||
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
|
|
||||||
} else {
|
/**
|
||||||
uncompressedPubKey = pubKeyHex;
|
* 从 MPC 公钥派生三条链的地址
|
||||||
}
|
*
|
||||||
|
* - BSC/KAVA: EVM 地址 (keccak256)
|
||||||
// ===== EVM 地址派生 (BSC, KAVA) =====
|
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
||||||
// 地址 = keccak256(公钥[1:])[12:]
|
*/
|
||||||
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
|
private async deriveChainAddresses(
|
||||||
const addressHash = ethers.keccak256(pubKeyBytes);
|
publicKey: string,
|
||||||
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
|
): Promise<{ chainType: string; address: string }[]> {
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
// ===== Cosmos 地址派生 (DST) =====
|
const { bech32 } = await import('bech32');
|
||||||
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
|
||||||
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest();
|
// MPC 公钥 (压缩格式,33 bytes)
|
||||||
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
||||||
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash));
|
const compressedPubKeyBytes = Buffer.from(
|
||||||
|
pubKeyHex.replace('0x', ''),
|
||||||
return [
|
'hex',
|
||||||
{ chainType: 'BSC', address: evmAddress },
|
);
|
||||||
{ chainType: 'KAVA', address: evmAddress },
|
|
||||||
{ chainType: 'DST', address: dstAddress },
|
// 解压公钥 (如果是压缩格式)
|
||||||
];
|
let uncompressedPubKey: string;
|
||||||
}
|
if (pubKeyHex.length === 68) {
|
||||||
|
// 压缩格式 (33 bytes = 66 hex chars + 0x)
|
||||||
/**
|
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
|
||||||
* 计算三个地址的联合摘要
|
} else {
|
||||||
*
|
uncompressedPubKey = pubKeyHex;
|
||||||
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
}
|
||||||
*/
|
|
||||||
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string {
|
// ===== EVM 地址派生 (BSC, KAVA) =====
|
||||||
// 按链类型排序以确保一致性
|
// 地址 = keccak256(公钥[1:])[12:]
|
||||||
const sortedAddresses = [...addresses].sort((a, b) =>
|
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
|
||||||
a.chainType.localeCompare(b.chainType),
|
const addressHash = ethers.keccak256(pubKeyBytes);
|
||||||
);
|
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
|
||||||
|
|
||||||
// 拼接地址
|
// ===== Cosmos 地址派生 (DST) =====
|
||||||
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join('');
|
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
||||||
|
const sha256Hash = createHash('sha256')
|
||||||
// 计算 SHA256 摘要
|
.update(compressedPubKeyBytes)
|
||||||
return createHash('sha256').update(concatenated).digest('hex');
|
.digest();
|
||||||
}
|
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
||||||
|
const dstAddress = bech32.encode(
|
||||||
/**
|
this.chainConfigs.DST.prefix,
|
||||||
* 计算单个地址的摘要
|
bech32.toWords(ripemd160Hash),
|
||||||
*/
|
);
|
||||||
private computeSingleAddressDigest(address: string, chainType: string): string {
|
|
||||||
const message = `${chainType}:${address.toLowerCase()}`;
|
return [
|
||||||
return createHash('sha256').update(message).digest('hex');
|
{ chainType: 'BSC', address: evmAddress },
|
||||||
}
|
{ chainType: 'KAVA', address: evmAddress },
|
||||||
|
{ chainType: 'DST', address: dstAddress },
|
||||||
/**
|
];
|
||||||
* 获取所有支持的链类型
|
}
|
||||||
*/
|
|
||||||
getSupportedChains(): string[] {
|
/**
|
||||||
return Object.keys(this.chainConfigs);
|
* 计算三个地址的联合摘要
|
||||||
}
|
*
|
||||||
}
|
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
||||||
|
*/
|
||||||
|
private computeAddressDigest(
|
||||||
|
addresses: { chainType: string; address: string }[],
|
||||||
|
): string {
|
||||||
|
// 按链类型排序以确保一致性
|
||||||
|
const sortedAddresses = [...addresses].sort((a, b) =>
|
||||||
|
a.chainType.localeCompare(b.chainType),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拼接地址
|
||||||
|
const concatenated = sortedAddresses
|
||||||
|
.map((a) => a.address.toLowerCase())
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// 计算 SHA256 摘要
|
||||||
|
return createHash('sha256').update(concatenated).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算单个地址的摘要
|
||||||
|
*/
|
||||||
|
private computeSingleAddressDigest(
|
||||||
|
address: string,
|
||||||
|
chainType: string,
|
||||||
|
): string {
|
||||||
|
const message = `${chainType}:${address.toLowerCase()}`;
|
||||||
|
return createHash('sha256').update(message).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有支持的链类型
|
||||||
|
*/
|
||||||
|
getSupportedChains(): string[] {
|
||||||
|
return Object.keys(this.chainConfigs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { MpcWalletService } from './mpc-wallet.service';
|
import { MpcWalletService } from './mpc-wallet.service';
|
||||||
import { MpcClientService } from './mpc-client.service';
|
import { MpcClientService } from './mpc-client.service';
|
||||||
import { KafkaModule } from '../../kafka/kafka.module';
|
import { KafkaModule } from '../../kafka/kafka.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({
|
HttpModule.register({
|
||||||
timeout: 300000, // MPC 操作可能需要较长时间
|
timeout: 300000, // MPC 操作可能需要较长时间
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
}),
|
}),
|
||||||
KafkaModule, // 用于事件驱动模式
|
KafkaModule, // 用于事件驱动模式
|
||||||
],
|
],
|
||||||
providers: [MpcWalletService, MpcClientService],
|
providers: [MpcWalletService, MpcClientService],
|
||||||
exports: [MpcWalletService, MpcClientService],
|
exports: [MpcWalletService, MpcClientService],
|
||||||
})
|
})
|
||||||
export class MpcModule {}
|
export class MpcModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [SmsService],
|
providers: [SmsService],
|
||||||
exports: [SmsService],
|
exports: [SmsService],
|
||||||
})
|
})
|
||||||
export class SmsModule {}
|
export class SmsModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,256 +1,293 @@
|
||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
||||||
import * as $OpenApi from '@alicloud/openapi-client';
|
import * as $OpenApi from '@alicloud/openapi-client';
|
||||||
import * as $Util from '@alicloud/tea-util';
|
import * as $Util from '@alicloud/tea-util';
|
||||||
|
|
||||||
export interface SmsSendResult {
|
export interface SmsSendResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
bizId?: string;
|
bizId?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService implements OnModuleInit {
|
export class SmsService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
private readonly logger = new Logger(SmsService.name);
|
||||||
private client: Dysmsapi20170525 | null = null;
|
private client: Dysmsapi20170525 | null = null;
|
||||||
private readonly signName: string;
|
private readonly signName: string;
|
||||||
private readonly templateCode: string;
|
private readonly templateCode: string;
|
||||||
private readonly enabled: boolean;
|
private readonly enabled: boolean;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const smsConfig = this.configService.get('smsConfig') || {};
|
const smsConfig = this.configService.get('smsConfig') || {};
|
||||||
const aliyunConfig = smsConfig.aliyun || {};
|
const aliyunConfig = smsConfig.aliyun || {};
|
||||||
|
|
||||||
this.signName = aliyunConfig.signName || this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
this.signName =
|
||||||
this.templateCode = aliyunConfig.templateCode || this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
aliyunConfig.signName ||
|
||||||
this.enabled = smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
||||||
}
|
this.templateCode =
|
||||||
|
aliyunConfig.templateCode ||
|
||||||
async onModuleInit() {
|
this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
||||||
await this.initClient();
|
this.enabled =
|
||||||
}
|
smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
||||||
|
}
|
||||||
private async initClient(): Promise<void> {
|
|
||||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
async onModuleInit() {
|
||||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
await this.initClient();
|
||||||
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
|
}
|
||||||
|
|
||||||
if (!accessKeyId || !accessKeySecret) {
|
private async initClient(): Promise<void> {
|
||||||
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||||
return;
|
const accessKeySecret = this.configService.get<string>(
|
||||||
}
|
'ALIYUN_ACCESS_KEY_SECRET',
|
||||||
|
);
|
||||||
try {
|
const endpoint = this.configService.get<string>(
|
||||||
const config = new $OpenApi.Config({
|
'ALIYUN_SMS_ENDPOINT',
|
||||||
accessKeyId,
|
'dysmsapi.aliyuncs.com',
|
||||||
accessKeySecret,
|
);
|
||||||
endpoint,
|
|
||||||
});
|
if (!accessKeyId || !accessKeySecret) {
|
||||||
|
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
||||||
this.client = new Dysmsapi20170525(config);
|
return;
|
||||||
this.logger.log('阿里云 SMS 客户端初始化成功');
|
}
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('阿里云 SMS 客户端初始化失败', error);
|
try {
|
||||||
}
|
const config = new $OpenApi.Config({
|
||||||
}
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
/**
|
endpoint,
|
||||||
* 发送验证码短信
|
});
|
||||||
*
|
|
||||||
* @param phoneNumber 手机号码(支持国际格式,如 +86xxx)
|
this.client = new Dysmsapi20170525(config);
|
||||||
* @param code 验证码
|
this.logger.log('阿里云 SMS 客户端初始化成功');
|
||||||
* @returns 发送结果
|
} catch (error) {
|
||||||
*/
|
this.logger.error('阿里云 SMS 客户端初始化失败', error);
|
||||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
}
|
||||||
// 标准化手机号(去除 +86 前缀)
|
}
|
||||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
||||||
|
/**
|
||||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
* 发送验证码短信
|
||||||
|
*
|
||||||
// 开发环境或未启用时,使用模拟模式
|
* @param phoneNumber 手机号码(支持国际格式,如 +86xxx)
|
||||||
if (!this.enabled || !this.client) {
|
* @param code 验证码
|
||||||
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
* @returns 发送结果
|
||||||
return {
|
*/
|
||||||
success: true,
|
async sendVerificationCode(
|
||||||
requestId: 'mock-request-id',
|
phoneNumber: string,
|
||||||
bizId: 'mock-biz-id',
|
code: string,
|
||||||
code: 'OK',
|
): Promise<SmsSendResult> {
|
||||||
message: '模拟发送成功',
|
// 标准化手机号(去除 +86 前缀)
|
||||||
};
|
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||||
}
|
|
||||||
|
this.logger.log(
|
||||||
try {
|
`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
);
|
||||||
phoneNumbers: normalizedPhone,
|
|
||||||
signName: this.signName,
|
// 开发环境或未启用时,使用模拟模式
|
||||||
templateCode: this.templateCode,
|
if (!this.enabled || !this.client) {
|
||||||
templateParam: JSON.stringify({ code }),
|
this.logger.warn(
|
||||||
});
|
`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
|
);
|
||||||
const runtime = new $Util.RuntimeOptions({
|
return {
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
success: true,
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
requestId: 'mock-request-id',
|
||||||
});
|
bizId: 'mock-biz-id',
|
||||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
code: 'OK',
|
||||||
|
message: '模拟发送成功',
|
||||||
const body = response.body;
|
};
|
||||||
const result: SmsSendResult = {
|
}
|
||||||
success: body?.code === 'OK',
|
|
||||||
requestId: body?.requestId,
|
try {
|
||||||
bizId: body?.bizId,
|
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||||
code: body?.code,
|
phoneNumbers: normalizedPhone,
|
||||||
message: body?.message,
|
signName: this.signName,
|
||||||
};
|
templateCode: this.templateCode,
|
||||||
|
templateParam: JSON.stringify({ code }),
|
||||||
if (result.success) {
|
});
|
||||||
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
|
|
||||||
} else {
|
const runtime = new $Util.RuntimeOptions({
|
||||||
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`);
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
}
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
|
});
|
||||||
return result;
|
const response = await this.client.sendSmsWithOptions(
|
||||||
} catch (error: any) {
|
sendSmsRequest,
|
||||||
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
|
runtime,
|
||||||
|
);
|
||||||
// 解析阿里云错误
|
|
||||||
if (error.code) {
|
const body = response.body;
|
||||||
return {
|
const result: SmsSendResult = {
|
||||||
success: false,
|
success: body?.code === 'OK',
|
||||||
code: error.code,
|
requestId: body?.requestId,
|
||||||
message: error.message || '短信发送失败',
|
bizId: body?.bizId,
|
||||||
};
|
code: body?.code,
|
||||||
}
|
message: body?.message,
|
||||||
|
};
|
||||||
return {
|
|
||||||
success: false,
|
if (result.success) {
|
||||||
code: 'UNKNOWN_ERROR',
|
this.logger.log(
|
||||||
message: error.message || '短信发送失败',
|
`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
|
||||||
};
|
);
|
||||||
}
|
} else {
|
||||||
}
|
this.logger.error(
|
||||||
|
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
|
||||||
/**
|
);
|
||||||
* 发送通用短信
|
}
|
||||||
*
|
|
||||||
* @param phoneNumber 手机号码
|
return result;
|
||||||
* @param templateCode 模板代码
|
} catch (error: any) {
|
||||||
* @param templateParam 模板参数
|
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
|
||||||
* @returns 发送结果
|
|
||||||
*/
|
// 解析阿里云错误
|
||||||
async sendSms(
|
if (error.code) {
|
||||||
phoneNumber: string,
|
return {
|
||||||
templateCode: string,
|
success: false,
|
||||||
templateParam: Record<string, string>,
|
code: error.code,
|
||||||
): Promise<SmsSendResult> {
|
message: error.message || '短信发送失败',
|
||||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
};
|
||||||
|
}
|
||||||
if (!this.enabled || !this.client) {
|
|
||||||
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
return {
|
||||||
return {
|
success: false,
|
||||||
success: true,
|
code: 'UNKNOWN_ERROR',
|
||||||
requestId: 'mock-request-id',
|
message: error.message || '短信发送失败',
|
||||||
code: 'OK',
|
};
|
||||||
message: '模拟发送成功',
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
try {
|
* 发送通用短信
|
||||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
*
|
||||||
phoneNumbers: normalizedPhone,
|
* @param phoneNumber 手机号码
|
||||||
signName: this.signName,
|
* @param templateCode 模板代码
|
||||||
templateCode,
|
* @param templateParam 模板参数
|
||||||
templateParam: JSON.stringify(templateParam),
|
* @returns 发送结果
|
||||||
});
|
*/
|
||||||
|
async sendSms(
|
||||||
const runtime = new $Util.RuntimeOptions({
|
phoneNumber: string,
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
templateCode: string,
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
templateParam: Record<string, string>,
|
||||||
});
|
): Promise<SmsSendResult> {
|
||||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
const body = response.body;
|
if (!this.enabled || !this.client) {
|
||||||
return {
|
this.logger.warn(
|
||||||
success: body?.code === 'OK',
|
`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||||
requestId: body?.requestId,
|
);
|
||||||
bizId: body?.bizId,
|
return {
|
||||||
code: body?.code,
|
success: true,
|
||||||
message: body?.message,
|
requestId: 'mock-request-id',
|
||||||
};
|
code: 'OK',
|
||||||
} catch (error: any) {
|
message: '模拟发送成功',
|
||||||
this.logger.error(`[SMS] 发送异常: ${error.message}`);
|
};
|
||||||
return {
|
}
|
||||||
success: false,
|
|
||||||
code: error.code || 'UNKNOWN_ERROR',
|
try {
|
||||||
message: error.message || '短信发送失败',
|
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||||
};
|
phoneNumbers: normalizedPhone,
|
||||||
}
|
signName: this.signName,
|
||||||
}
|
templateCode,
|
||||||
|
templateParam: JSON.stringify(templateParam),
|
||||||
/**
|
});
|
||||||
* 查询短信发送状态
|
|
||||||
*
|
const runtime = new $Util.RuntimeOptions({
|
||||||
* @param phoneNumber 手机号码
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
* @param bizId 发送回执ID
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
* @param sendDate 发送日期 (yyyyMMdd 格式)
|
});
|
||||||
*/
|
const response = await this.client.sendSmsWithOptions(
|
||||||
async querySendDetails(
|
sendSmsRequest,
|
||||||
phoneNumber: string,
|
runtime,
|
||||||
bizId: string,
|
);
|
||||||
sendDate: string,
|
|
||||||
): Promise<any> {
|
const body = response.body;
|
||||||
if (!this.client) {
|
return {
|
||||||
this.logger.warn('[SMS] 客户端未初始化,无法查询');
|
success: body?.code === 'OK',
|
||||||
return null;
|
requestId: body?.requestId,
|
||||||
}
|
bizId: body?.bizId,
|
||||||
|
code: body?.code,
|
||||||
try {
|
message: body?.message,
|
||||||
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({
|
};
|
||||||
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
} catch (error: any) {
|
||||||
bizId,
|
this.logger.error(`[SMS] 发送异常: ${error.message}`);
|
||||||
sendDate,
|
return {
|
||||||
pageSize: 10,
|
success: false,
|
||||||
currentPage: 1,
|
code: error.code || 'UNKNOWN_ERROR',
|
||||||
});
|
message: error.message || '短信发送失败',
|
||||||
|
};
|
||||||
const runtime = new $Util.RuntimeOptions({
|
}
|
||||||
connectTimeout: 10000, // 连接超时 10 秒
|
}
|
||||||
readTimeout: 10000, // 读取超时 10 秒
|
|
||||||
});
|
/**
|
||||||
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime);
|
* 查询短信发送状态
|
||||||
|
*
|
||||||
return response.body;
|
* @param phoneNumber 手机号码
|
||||||
} catch (error: any) {
|
* @param bizId 发送回执ID
|
||||||
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
|
* @param sendDate 发送日期 (yyyyMMdd 格式)
|
||||||
return null;
|
*/
|
||||||
}
|
async querySendDetails(
|
||||||
}
|
phoneNumber: string,
|
||||||
|
bizId: string,
|
||||||
/**
|
sendDate: string,
|
||||||
* 标准化手机号(去除国际区号前缀)
|
): Promise<any> {
|
||||||
*/
|
if (!this.client) {
|
||||||
private normalizePhoneNumber(phoneNumber: string): string {
|
this.logger.warn('[SMS] 客户端未初始化,无法查询');
|
||||||
let normalized = phoneNumber.trim();
|
return null;
|
||||||
|
}
|
||||||
// 去除 +86 或 86 前缀
|
|
||||||
if (normalized.startsWith('+86')) {
|
try {
|
||||||
normalized = normalized.substring(3);
|
const querySendDetailsRequest =
|
||||||
} else if (normalized.startsWith('86') && normalized.length === 13) {
|
new $Dysmsapi20170525.QuerySendDetailsRequest({
|
||||||
normalized = normalized.substring(2);
|
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
||||||
}
|
bizId,
|
||||||
|
sendDate,
|
||||||
return normalized;
|
pageSize: 10,
|
||||||
}
|
currentPage: 1,
|
||||||
|
});
|
||||||
/**
|
|
||||||
* 脱敏手机号(用于日志)
|
const runtime = new $Util.RuntimeOptions({
|
||||||
*/
|
connectTimeout: 10000, // 连接超时 10 秒
|
||||||
private maskPhoneNumber(phoneNumber: string): string {
|
readTimeout: 10000, // 读取超时 10 秒
|
||||||
if (phoneNumber.length < 7) {
|
});
|
||||||
return phoneNumber;
|
const response = await this.client.querySendDetailsWithOptions(
|
||||||
}
|
querySendDetailsRequest,
|
||||||
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4);
|
runtime,
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
return response.body;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化手机号(去除国际区号前缀)
|
||||||
|
*/
|
||||||
|
private normalizePhoneNumber(phoneNumber: string): string {
|
||||||
|
let normalized = phoneNumber.trim();
|
||||||
|
|
||||||
|
// 去除 +86 或 86 前缀
|
||||||
|
if (normalized.startsWith('+86')) {
|
||||||
|
normalized = normalized.substring(3);
|
||||||
|
} else if (normalized.startsWith('86') && normalized.length === 13) {
|
||||||
|
normalized = normalized.substring(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脱敏手机号(用于日志)
|
||||||
|
*/
|
||||||
|
private maskPhoneNumber(phoneNumber: string): string {
|
||||||
|
if (phoneNumber.length < 7) {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
phoneNumber.substring(0, 3) +
|
||||||
|
'****' +
|
||||||
|
phoneNumber.substring(phoneNumber.length - 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,30 @@ export class StorageService implements OnModuleInit {
|
||||||
private publicUrl: string;
|
private publicUrl: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost');
|
const endpoint = this.configService.get<string>(
|
||||||
|
'MINIO_ENDPOINT',
|
||||||
|
'localhost',
|
||||||
|
);
|
||||||
const port = this.configService.get<number>('MINIO_PORT', 9000);
|
const port = this.configService.get<number>('MINIO_PORT', 9000);
|
||||||
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
const useSSL =
|
||||||
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
|
this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||||
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
|
const accessKey = this.configService.get<string>(
|
||||||
|
'MINIO_ACCESS_KEY',
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
const secretKey = this.configService.get<string>(
|
||||||
|
'MINIO_SECRET_KEY',
|
||||||
|
'minio_secret_password',
|
||||||
|
);
|
||||||
|
|
||||||
this.bucketAvatars = this.configService.get<string>('MINIO_BUCKET_AVATARS', 'avatars');
|
this.bucketAvatars = this.configService.get<string>(
|
||||||
this.publicUrl = this.configService.get<string>('MINIO_PUBLIC_URL', 'http://localhost:9000');
|
'MINIO_BUCKET_AVATARS',
|
||||||
|
'avatars',
|
||||||
|
);
|
||||||
|
this.publicUrl = this.configService.get<string>(
|
||||||
|
'MINIO_PUBLIC_URL',
|
||||||
|
'http://localhost:9000',
|
||||||
|
);
|
||||||
|
|
||||||
this.client = new Minio.Client({
|
this.client = new Minio.Client({
|
||||||
endPoint: endpoint,
|
endPoint: endpoint,
|
||||||
|
|
@ -83,7 +99,9 @@ export class StorageService implements OnModuleInit {
|
||||||
this.logger.log(`Bucket exists: ${bucketName}`);
|
this.logger.log(`Bucket exists: ${bucketName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`);
|
this.logger.error(
|
||||||
|
`Failed to ensure bucket ${bucketName}: ${error.message}`,
|
||||||
|
);
|
||||||
// 不抛出异常,允许服务启动(MinIO可能暂时不可用)
|
// 不抛出异常,允许服务启动(MinIO可能暂时不可用)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +174,7 @@ export class StorageService implements OnModuleInit {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
// URL格式: http://host/bucket/key
|
// URL格式: http://host/bucket/key
|
||||||
const pathParts = urlObj.pathname.split('/').filter(p => p);
|
const pathParts = urlObj.pathname.split('/').filter((p) => p);
|
||||||
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
|
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
|
||||||
return pathParts.slice(1).join('/');
|
return pathParts.slice(1).join('/');
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +202,13 @@ export class StorageService implements OnModuleInit {
|
||||||
* 验证图片类型
|
* 验证图片类型
|
||||||
*/
|
*/
|
||||||
isValidImageType(contentType: string): boolean {
|
isValidImageType(contentType: string): boolean {
|
||||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
const validTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
return validTypes.includes(contentType);
|
return validTypes.includes(contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,65 @@
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module, Global } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||||
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
||||||
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
|
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
|
||||||
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
||||||
import { RedisService } from './redis/redis.service';
|
import { RedisService } from './redis/redis.service';
|
||||||
import { EventPublisherService } from './kafka/event-publisher.service';
|
import { EventPublisherService } from './kafka/event-publisher.service';
|
||||||
import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
|
import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
|
||||||
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
||||||
import { SmsService } from './external/sms/sms.service';
|
import { SmsService } from './external/sms/sms.service';
|
||||||
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
||||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||||
import { StorageService } from './external/storage/storage.service';
|
import { StorageService } from './external/storage/storage.service';
|
||||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
HttpModule.register({
|
HttpModule.register({
|
||||||
timeout: 300000,
|
timeout: 300000,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserAccountRepositoryImpl,
|
UserAccountRepositoryImpl,
|
||||||
{
|
{
|
||||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||||
useClass: MpcKeyShareRepositoryImpl,
|
useClass: MpcKeyShareRepositoryImpl,
|
||||||
},
|
},
|
||||||
UserAccountMapper,
|
UserAccountMapper,
|
||||||
RedisService,
|
RedisService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
SmsService,
|
SmsService,
|
||||||
// BlockchainClientService 调用 blockchain-service API
|
// BlockchainClientService 调用 blockchain-service API
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
MpcWalletService,
|
MpcWalletService,
|
||||||
StorageService,
|
StorageService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserAccountRepositoryImpl,
|
UserAccountRepositoryImpl,
|
||||||
{
|
{
|
||||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||||
useClass: MpcKeyShareRepositoryImpl,
|
useClass: MpcKeyShareRepositoryImpl,
|
||||||
},
|
},
|
||||||
UserAccountMapper,
|
UserAccountMapper,
|
||||||
RedisService,
|
RedisService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
SmsService,
|
SmsService,
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
MpcWalletService,
|
MpcWalletService,
|
||||||
StorageService,
|
StorageService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
|
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
|
|
@ -22,15 +27,17 @@ export interface WalletAddressCreatedPayload {
|
||||||
address: string;
|
address: string;
|
||||||
}[];
|
}[];
|
||||||
// 恢复助记词相关
|
// 恢复助记词相关
|
||||||
mnemonic?: string; // 12词助记词 (明文)
|
mnemonic?: string; // 12词助记词 (明文)
|
||||||
encryptedMnemonic?: string; // 加密的助记词
|
encryptedMnemonic?: string; // 加密的助记词
|
||||||
mnemonicHash?: string; // 助记词哈希
|
mnemonicHash?: string; // 助记词哈希
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
export class BlockchainEventConsumerService
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
private readonly logger = new Logger(BlockchainEventConsumerService.name);
|
private readonly logger = new Logger(BlockchainEventConsumerService.name);
|
||||||
private kafka: Kafka;
|
private kafka: Kafka;
|
||||||
private consumer: Consumer;
|
private consumer: Consumer;
|
||||||
|
|
@ -41,15 +48,20 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
.get<string>('KAFKA_BROKERS')
|
||||||
|
?.split(',') || ['localhost:9092'];
|
||||||
|
const clientId =
|
||||||
|
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||||
const groupId = 'identity-service-blockchain-events';
|
const groupId = 'identity-service-blockchain-events';
|
||||||
|
|
||||||
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
|
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||||
this.kafka = new Kafka({
|
this.kafka = new Kafka({
|
||||||
|
|
@ -57,10 +69,10 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
brokers,
|
brokers,
|
||||||
logLevel: logLevel.WARN,
|
logLevel: logLevel.WARN,
|
||||||
retry: {
|
retry: {
|
||||||
initialRetryTime: 1000, // 1 秒
|
initialRetryTime: 1000, // 1 秒
|
||||||
maxRetryTime: 300000, // 最大 5 分钟
|
maxRetryTime: 300000, // 最大 5 分钟
|
||||||
retries: 15, // 最多 15 次
|
retries: 15, // 最多 15 次
|
||||||
multiplier: 2, // 指数退避因子
|
multiplier: 2, // 指数退避因子
|
||||||
restartOnFailure: async () => true,
|
restartOnFailure: async () => true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -75,16 +87,26 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
|
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
|
||||||
await this.consumer.connect();
|
await this.consumer.connect();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.logger.log(`[CONNECT] Blockchain Event Kafka consumer connected successfully`);
|
this.logger.log(
|
||||||
|
`[CONNECT] Blockchain Event Kafka consumer connected successfully`,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to blockchain topics
|
// Subscribe to blockchain topics
|
||||||
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false });
|
await this.consumer.subscribe({
|
||||||
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
topics: Object.values(BLOCKCHAIN_TOPICS),
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Start consuming
|
// Start consuming
|
||||||
await this.startConsuming();
|
await this.startConsuming();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to connect Blockchain Event Kafka consumer`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to connect Blockchain Event Kafka consumer`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,16 +120,24 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
/**
|
/**
|
||||||
* Register handler for wallet address created events
|
* Register handler for wallet address created events
|
||||||
*/
|
*/
|
||||||
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void {
|
onWalletAddressCreated(
|
||||||
|
handler: BlockchainEventHandler<WalletAddressCreatedPayload>,
|
||||||
|
): void {
|
||||||
this.walletAddressCreatedHandler = handler;
|
this.walletAddressCreatedHandler = handler;
|
||||||
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
|
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConsuming(): Promise<void> {
|
private async startConsuming(): Promise<void> {
|
||||||
await this.consumer.run({
|
await this.consumer.run({
|
||||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
eachMessage: async ({
|
||||||
|
topic,
|
||||||
|
partition,
|
||||||
|
message,
|
||||||
|
}: EachMessagePayload) => {
|
||||||
const offset = message.offset;
|
const offset = message.offset;
|
||||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = message.value?.toString();
|
const value = message.value?.toString();
|
||||||
|
|
@ -116,33 +146,53 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const payload = parsed.payload || parsed;
|
const payload = parsed.payload || parsed;
|
||||||
const eventType = parsed.eventType || 'unknown';
|
const eventType = parsed.eventType || 'unknown';
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
|
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
|
||||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle WalletAddressCreated events
|
// Handle WalletAddressCreated events
|
||||||
if (eventType === 'blockchain.wallet.address.created' || topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED) {
|
if (
|
||||||
|
eventType === 'blockchain.wallet.address.created' ||
|
||||||
|
topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED
|
||||||
|
) {
|
||||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
|
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
|
||||||
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
|
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
|
||||||
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
|
this.logger.log(
|
||||||
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`);
|
`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] addresses count: ${payload.addresses?.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (this.walletAddressCreatedHandler) {
|
if (this.walletAddressCreatedHandler) {
|
||||||
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload);
|
await this.walletAddressCreatedHandler(
|
||||||
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`);
|
payload as WalletAddressCreatedPayload,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] WalletAddressCreated handler completed successfully`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for WalletAddressCreated`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
|
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing blockchain event from ${topic}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Error processing blockchain event from ${topic}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,85 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||||
import { DomainEventMessage } from './event-publisher.service';
|
import { DomainEventMessage } from './event-publisher.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeadLetterService {
|
export class DeadLetterService {
|
||||||
private readonly logger = new Logger(DeadLetterService.name);
|
private readonly logger = new Logger(DeadLetterService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async saveFailedEvent(
|
async saveFailedEvent(
|
||||||
topic: string,
|
topic: string,
|
||||||
message: DomainEventMessage,
|
message: DomainEventMessage,
|
||||||
error: Error,
|
error: Error,
|
||||||
retryCount: number,
|
retryCount: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.prisma.deadLetterEvent.create({
|
await this.prisma.deadLetterEvent.create({
|
||||||
data: {
|
data: {
|
||||||
topic,
|
topic,
|
||||||
eventId: message.eventId,
|
eventId: message.eventId,
|
||||||
eventType: message.eventType,
|
eventType: message.eventType,
|
||||||
aggregateId: message.aggregateId,
|
aggregateId: message.aggregateId,
|
||||||
aggregateType: message.aggregateType,
|
aggregateType: message.aggregateType,
|
||||||
payload: message.payload,
|
payload: message.payload,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
errorStack: error.stack,
|
errorStack: error.stack,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
|
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFailedEvents(limit: number = 100): Promise<any[]> {
|
async getFailedEvents(limit: number = 100): Promise<any[]> {
|
||||||
return this.prisma.deadLetterEvent.findMany({
|
return this.prisma.deadLetterEvent.findMany({
|
||||||
where: { processedAt: null },
|
where: { processedAt: null },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsProcessed(id: bigint): Promise<void> {
|
async markAsProcessed(id: bigint): Promise<void> {
|
||||||
await this.prisma.deadLetterEvent.update({
|
await this.prisma.deadLetterEvent.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { processedAt: new Date() },
|
data: { processedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Dead letter event marked as processed: ${id}`);
|
this.logger.log(`Dead letter event marked as processed: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async incrementRetryCount(id: bigint): Promise<void> {
|
async incrementRetryCount(id: bigint): Promise<void> {
|
||||||
await this.prisma.deadLetterEvent.update({
|
await this.prisma.deadLetterEvent.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { retryCount: { increment: 1 } },
|
data: { retryCount: { increment: 1 } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatistics(): Promise<{
|
async getStatistics(): Promise<{
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
processed: number;
|
processed: number;
|
||||||
byTopic: Record<string, number>;
|
byTopic: Record<string, number>;
|
||||||
}> {
|
}> {
|
||||||
const [total, pending, processed, byTopic] = await Promise.all([
|
const [total, pending, processed, byTopic] = await Promise.all([
|
||||||
this.prisma.deadLetterEvent.count(),
|
this.prisma.deadLetterEvent.count(),
|
||||||
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
||||||
this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }),
|
this.prisma.deadLetterEvent.count({
|
||||||
this.prisma.deadLetterEvent.groupBy({
|
where: { processedAt: { not: null } },
|
||||||
by: ['topic'],
|
}),
|
||||||
_count: true,
|
this.prisma.deadLetterEvent.groupBy({
|
||||||
where: { processedAt: null },
|
by: ['topic'],
|
||||||
}),
|
_count: true,
|
||||||
]);
|
where: { processedAt: null },
|
||||||
|
}),
|
||||||
const topicStats: Record<string, number> = {};
|
]);
|
||||||
for (const item of byTopic) {
|
|
||||||
topicStats[item.topic] = item._count;
|
const topicStats: Record<string, number> = {};
|
||||||
}
|
for (const item of byTopic) {
|
||||||
|
topicStats[item.topic] = item._count;
|
||||||
return { total, pending, processed, byTopic: topicStats };
|
}
|
||||||
}
|
|
||||||
}
|
return { total, pending, processed, byTopic: topicStats };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,237 +1,239 @@
|
||||||
import { Controller, Logger } from '@nestjs/common';
|
import { Controller, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
MessagePattern,
|
MessagePattern,
|
||||||
Payload,
|
Payload,
|
||||||
Ctx,
|
Ctx,
|
||||||
KafkaContext,
|
KafkaContext,
|
||||||
} from '@nestjs/microservices';
|
} from '@nestjs/microservices';
|
||||||
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
|
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class EventConsumerController {
|
export class EventConsumerController {
|
||||||
private readonly logger = new Logger(EventConsumerController.name);
|
private readonly logger = new Logger(EventConsumerController.name);
|
||||||
|
|
||||||
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
|
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
|
||||||
async handleUserAccountCreated(
|
async handleUserAccountCreated(
|
||||||
@Payload() message: DomainEventMessage,
|
@Payload() message: DomainEventMessage,
|
||||||
@Ctx() context: KafkaContext,
|
@Ctx() context: KafkaContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { offset } = context.getMessage();
|
const { offset } = context.getMessage();
|
||||||
const partition = context.getPartition();
|
const partition = context.getPartition();
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processUserAccountCreated(message.payload);
|
await this.processUserAccountCreated(message.payload);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Successfully processed UserAccountCreated: ${message.eventId}`,
|
`Successfully processed UserAccountCreated: ${message.eventId}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to process UserAccountCreated: ${message.eventId}`,
|
`Failed to process UserAccountCreated: ${message.eventId}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
|
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
|
||||||
async handleDeviceAdded(
|
async handleDeviceAdded(
|
||||||
@Payload() message: DomainEventMessage,
|
@Payload() message: DomainEventMessage,
|
||||||
@Ctx() context: KafkaContext,
|
@Ctx() context: KafkaContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { offset } = context.getMessage();
|
const { offset } = context.getMessage();
|
||||||
const partition = context.getPartition();
|
const partition = context.getPartition();
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processDeviceAdded(message.payload);
|
await this.processDeviceAdded(message.payload);
|
||||||
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
|
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to process DeviceAdded: ${message.eventId}`,
|
`Failed to process DeviceAdded: ${message.eventId}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
|
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
|
||||||
async handlePhoneBound(
|
async handlePhoneBound(
|
||||||
@Payload() message: DomainEventMessage,
|
@Payload() message: DomainEventMessage,
|
||||||
@Ctx() context: KafkaContext,
|
@Ctx() context: KafkaContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { offset } = context.getMessage();
|
const { offset } = context.getMessage();
|
||||||
const partition = context.getPartition();
|
const partition = context.getPartition();
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processPhoneBound(message.payload);
|
await this.processPhoneBound(message.payload);
|
||||||
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
|
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to process PhoneBound: ${message.eventId}`,
|
`Failed to process PhoneBound: ${message.eventId}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
|
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
|
||||||
async handleKYCSubmitted(
|
async handleKYCSubmitted(
|
||||||
@Payload() message: DomainEventMessage,
|
@Payload() message: DomainEventMessage,
|
||||||
@Ctx() context: KafkaContext,
|
@Ctx() context: KafkaContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
|
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processKYCSubmitted(message.payload);
|
await this.processKYCSubmitted(message.payload);
|
||||||
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`);
|
this.logger.log(
|
||||||
} catch (error) {
|
`Successfully processed KYCSubmitted: ${message.eventId}`,
|
||||||
this.logger.error(
|
);
|
||||||
`Failed to process KYCSubmitted: ${message.eventId}`,
|
} catch (error) {
|
||||||
error,
|
this.logger.error(
|
||||||
);
|
`Failed to process KYCSubmitted: ${message.eventId}`,
|
||||||
throw error;
|
error,
|
||||||
}
|
);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
|
}
|
||||||
async handleKYCApproved(
|
|
||||||
@Payload() message: DomainEventMessage,
|
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
|
||||||
@Ctx() context: KafkaContext,
|
async handleKYCApproved(
|
||||||
): Promise<void> {
|
@Payload() message: DomainEventMessage,
|
||||||
this.logger.log(`Received KYCApproved event: ${message.eventId}`);
|
@Ctx() context: KafkaContext,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
this.logger.log(`Received KYCApproved event: ${message.eventId}`);
|
||||||
await this.processKYCApproved(message.payload);
|
|
||||||
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
|
try {
|
||||||
} catch (error) {
|
await this.processKYCApproved(message.payload);
|
||||||
this.logger.error(
|
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
|
||||||
`Failed to process KYCApproved: ${message.eventId}`,
|
} catch (error) {
|
||||||
error,
|
this.logger.error(
|
||||||
);
|
`Failed to process KYCApproved: ${message.eventId}`,
|
||||||
throw error;
|
error,
|
||||||
}
|
);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
|
}
|
||||||
async handleKYCRejected(
|
|
||||||
@Payload() message: DomainEventMessage,
|
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
|
||||||
@Ctx() context: KafkaContext,
|
async handleKYCRejected(
|
||||||
): Promise<void> {
|
@Payload() message: DomainEventMessage,
|
||||||
this.logger.log(`Received KYCRejected event: ${message.eventId}`);
|
@Ctx() context: KafkaContext,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
this.logger.log(`Received KYCRejected event: ${message.eventId}`);
|
||||||
await this.processKYCRejected(message.payload);
|
|
||||||
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
|
try {
|
||||||
} catch (error) {
|
await this.processKYCRejected(message.payload);
|
||||||
this.logger.error(
|
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
|
||||||
`Failed to process KYCRejected: ${message.eventId}`,
|
} catch (error) {
|
||||||
error,
|
this.logger.error(
|
||||||
);
|
`Failed to process KYCRejected: ${message.eventId}`,
|
||||||
throw error;
|
error,
|
||||||
}
|
);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
|
}
|
||||||
async handleAccountFrozen(
|
|
||||||
@Payload() message: DomainEventMessage,
|
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
|
||||||
@Ctx() context: KafkaContext,
|
async handleAccountFrozen(
|
||||||
): Promise<void> {
|
@Payload() message: DomainEventMessage,
|
||||||
this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
|
@Ctx() context: KafkaContext,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
|
||||||
await this.processAccountFrozen(message.payload);
|
|
||||||
this.logger.log(
|
try {
|
||||||
`Successfully processed AccountFrozen: ${message.eventId}`,
|
await this.processAccountFrozen(message.payload);
|
||||||
);
|
this.logger.log(
|
||||||
} catch (error) {
|
`Successfully processed AccountFrozen: ${message.eventId}`,
|
||||||
this.logger.error(
|
);
|
||||||
`Failed to process AccountFrozen: ${message.eventId}`,
|
} catch (error) {
|
||||||
error,
|
this.logger.error(
|
||||||
);
|
`Failed to process AccountFrozen: ${message.eventId}`,
|
||||||
throw error;
|
error,
|
||||||
}
|
);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
|
}
|
||||||
async handleWalletBound(
|
|
||||||
@Payload() message: DomainEventMessage,
|
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
|
||||||
@Ctx() context: KafkaContext,
|
async handleWalletBound(
|
||||||
): Promise<void> {
|
@Payload() message: DomainEventMessage,
|
||||||
this.logger.log(`Received WalletBound event: ${message.eventId}`);
|
@Ctx() context: KafkaContext,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
this.logger.log(`Received WalletBound event: ${message.eventId}`);
|
||||||
await this.processWalletBound(message.payload);
|
|
||||||
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
|
try {
|
||||||
} catch (error) {
|
await this.processWalletBound(message.payload);
|
||||||
this.logger.error(
|
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
|
||||||
`Failed to process WalletBound: ${message.eventId}`,
|
} catch (error) {
|
||||||
error,
|
this.logger.error(
|
||||||
);
|
`Failed to process WalletBound: ${message.eventId}`,
|
||||||
throw error;
|
error,
|
||||||
}
|
);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
// 业务处理方法
|
}
|
||||||
private async processUserAccountCreated(payload: any): Promise<void> {
|
|
||||||
this.logger.debug(
|
// 业务处理方法
|
||||||
`Processing UserAccountCreated: userId=${payload.userId}`,
|
private async processUserAccountCreated(payload: any): Promise<void> {
|
||||||
);
|
this.logger.debug(
|
||||||
// 发送欢迎通知
|
`Processing UserAccountCreated: userId=${payload.userId}`,
|
||||||
// 初始化用户积分
|
);
|
||||||
// 记录邀请关系
|
// 发送欢迎通知
|
||||||
}
|
// 初始化用户积分
|
||||||
|
// 记录邀请关系
|
||||||
private async processDeviceAdded(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(
|
|
||||||
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
|
private async processDeviceAdded(payload: any): Promise<void> {
|
||||||
);
|
this.logger.debug(
|
||||||
// 发送新设备登录通知
|
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
|
||||||
// 安全审计记录
|
);
|
||||||
}
|
// 发送新设备登录通知
|
||||||
|
// 安全审计记录
|
||||||
private async processPhoneBound(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
|
|
||||||
// 发送绑定成功短信
|
private async processPhoneBound(payload: any): Promise<void> {
|
||||||
}
|
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
|
||||||
|
// 发送绑定成功短信
|
||||||
private async processKYCSubmitted(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
|
|
||||||
// 触发KYC审核流程
|
private async processKYCSubmitted(payload: any): Promise<void> {
|
||||||
// 通知审核人员
|
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
|
||||||
}
|
// 触发KYC审核流程
|
||||||
|
// 通知审核人员
|
||||||
private async processKYCApproved(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
|
|
||||||
// 发送审核通过通知
|
private async processKYCApproved(payload: any): Promise<void> {
|
||||||
// 解锁高级功能
|
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
|
||||||
}
|
// 发送审核通过通知
|
||||||
|
// 解锁高级功能
|
||||||
private async processKYCRejected(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
|
|
||||||
// 发送审核失败通知
|
private async processKYCRejected(payload: any): Promise<void> {
|
||||||
}
|
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
|
||||||
|
// 发送审核失败通知
|
||||||
private async processAccountFrozen(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
|
|
||||||
// 发送账户冻结通知
|
private async processAccountFrozen(payload: any): Promise<void> {
|
||||||
// 清除用户会话
|
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
|
||||||
}
|
// 发送账户冻结通知
|
||||||
|
// 清除用户会话
|
||||||
private async processWalletBound(payload: any): Promise<void> {
|
}
|
||||||
this.logger.debug(
|
|
||||||
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
|
private async processWalletBound(payload: any): Promise<void> {
|
||||||
);
|
this.logger.debug(
|
||||||
// 同步钱包余额
|
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
|
||||||
}
|
);
|
||||||
}
|
// 同步钱包余额
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,157 +1,176 @@
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
import { ConfigService } from '@nestjs/config';
|
Injectable,
|
||||||
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
Logger,
|
||||||
import { DomainEvent } from '@/domain/events';
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
// 定义 Kafka 消息接口
|
} from '@nestjs/common';
|
||||||
export interface DomainEventMessage {
|
import { ConfigService } from '@nestjs/config';
|
||||||
eventId: string;
|
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||||
eventType: string;
|
import { DomainEvent } from '@/domain/events';
|
||||||
occurredAt: string;
|
|
||||||
aggregateId: string;
|
// 定义 Kafka 消息接口
|
||||||
aggregateType: string;
|
export interface DomainEventMessage {
|
||||||
payload: any;
|
eventId: string;
|
||||||
}
|
eventType: string;
|
||||||
|
occurredAt: string;
|
||||||
// 定义主题常量 - identity-service 发布的事件
|
aggregateId: string;
|
||||||
export const IDENTITY_TOPICS = {
|
aggregateType: string;
|
||||||
USER_ACCOUNT_CREATED: 'identity.UserAccountCreated',
|
payload: any;
|
||||||
USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated',
|
}
|
||||||
DEVICE_ADDED: 'identity.DeviceAdded',
|
|
||||||
DEVICE_REMOVED: 'identity.DeviceRemoved',
|
// 定义主题常量 - identity-service 发布的事件
|
||||||
PHONE_BOUND: 'identity.PhoneBound',
|
export const IDENTITY_TOPICS = {
|
||||||
WALLET_BOUND: 'identity.WalletBound',
|
USER_ACCOUNT_CREATED: 'identity.UserAccountCreated',
|
||||||
MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound',
|
USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated',
|
||||||
KYC_SUBMITTED: 'identity.KYCSubmitted',
|
DEVICE_ADDED: 'identity.DeviceAdded',
|
||||||
KYC_VERIFIED: 'identity.KYCVerified',
|
DEVICE_REMOVED: 'identity.DeviceRemoved',
|
||||||
KYC_REJECTED: 'identity.KYCRejected',
|
PHONE_BOUND: 'identity.PhoneBound',
|
||||||
KYC_APPROVED: 'identity.KYCApproved',
|
WALLET_BOUND: 'identity.WalletBound',
|
||||||
USER_LOCATION_UPDATED: 'identity.UserLocationUpdated',
|
MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound',
|
||||||
USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen',
|
KYC_SUBMITTED: 'identity.KYCSubmitted',
|
||||||
ACCOUNT_FROZEN: 'identity.AccountFrozen',
|
KYC_VERIFIED: 'identity.KYCVerified',
|
||||||
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated',
|
KYC_REJECTED: 'identity.KYCRejected',
|
||||||
// MPC 请求发送到 mpc.* topic,让 mpc-service 消费
|
KYC_APPROVED: 'identity.KYCApproved',
|
||||||
MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested',
|
USER_LOCATION_UPDATED: 'identity.UserLocationUpdated',
|
||||||
MPC_SIGNING_REQUESTED: 'mpc.SigningRequested',
|
USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen',
|
||||||
} as const;
|
ACCOUNT_FROZEN: 'identity.AccountFrozen',
|
||||||
|
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated',
|
||||||
// 定义 identity-service 需要消费的 MPC 事件主题
|
// MPC 请求发送到 mpc.* topic,让 mpc-service 消费
|
||||||
export const MPC_CONSUME_TOPICS = {
|
MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested',
|
||||||
KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
|
MPC_SIGNING_REQUESTED: 'mpc.SigningRequested',
|
||||||
SESSION_FAILED: 'mpc.SessionFailed',
|
} as const;
|
||||||
} as const;
|
|
||||||
|
// 定义 identity-service 需要消费的 MPC 事件主题
|
||||||
@Injectable()
|
export const MPC_CONSUME_TOPICS = {
|
||||||
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
|
||||||
private readonly logger = new Logger(EventPublisherService.name);
|
SESSION_FAILED: 'mpc.SessionFailed',
|
||||||
private kafka: Kafka;
|
} as const;
|
||||||
private producer: Producer;
|
|
||||||
|
@Injectable()
|
||||||
constructor(private readonly configService: ConfigService) {
|
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
private readonly logger = new Logger(EventPublisherService.name);
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
|
private kafka: Kafka;
|
||||||
|
private producer: Producer;
|
||||||
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
const brokers = this.configService
|
||||||
|
.get<string>('KAFKA_BROKERS', 'localhost:9092')
|
||||||
this.kafka = new Kafka({
|
.split(',');
|
||||||
clientId,
|
const clientId = this.configService.get<string>(
|
||||||
brokers,
|
'KAFKA_CLIENT_ID',
|
||||||
logLevel: logLevel.WARN,
|
'identity-service',
|
||||||
});
|
);
|
||||||
this.producer = this.kafka.producer();
|
|
||||||
}
|
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
||||||
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
async onModuleInit() {
|
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||||
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
|
|
||||||
await this.producer.connect();
|
this.kafka = new Kafka({
|
||||||
this.logger.log(`[CONNECT] Kafka producer connected successfully`);
|
clientId,
|
||||||
}
|
brokers,
|
||||||
|
logLevel: logLevel.WARN,
|
||||||
async onModuleDestroy() {
|
});
|
||||||
this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`);
|
this.producer = this.kafka.producer();
|
||||||
await this.producer.disconnect();
|
}
|
||||||
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
|
|
||||||
}
|
async onModuleInit() {
|
||||||
|
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
|
||||||
async publish(event: DomainEvent): Promise<void>;
|
await this.producer.connect();
|
||||||
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
this.logger.log(`[CONNECT] Kafka producer connected successfully`);
|
||||||
async publish(eventOrTopic: DomainEvent | string, message?: DomainEventMessage): Promise<void> {
|
}
|
||||||
if (typeof eventOrTopic === 'string') {
|
|
||||||
// 直接发布到指定 topic (用于重试场景)
|
async onModuleDestroy() {
|
||||||
const topic = eventOrTopic;
|
this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`);
|
||||||
const msg = message!;
|
await this.producer.disconnect();
|
||||||
|
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
|
||||||
this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`);
|
}
|
||||||
this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
|
|
||||||
|
async publish(event: DomainEvent): Promise<void>;
|
||||||
await this.producer.send({
|
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
||||||
topic,
|
async publish(
|
||||||
messages: [
|
eventOrTopic: DomainEvent | string,
|
||||||
{
|
message?: DomainEventMessage,
|
||||||
key: msg.eventId,
|
): Promise<void> {
|
||||||
value: JSON.stringify(msg),
|
if (typeof eventOrTopic === 'string') {
|
||||||
},
|
// 直接发布到指定 topic (用于重试场景)
|
||||||
],
|
const topic = eventOrTopic;
|
||||||
});
|
const msg = message!;
|
||||||
|
|
||||||
this.logger.log(`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`);
|
this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`);
|
||||||
} else {
|
this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
|
||||||
// 从领域事件发布
|
|
||||||
const event = eventOrTopic;
|
await this.producer.send({
|
||||||
const topic = this.getTopicForEvent(event);
|
topic,
|
||||||
const payload = (event as any).payload;
|
messages: [
|
||||||
|
{
|
||||||
this.logger.log(`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`);
|
key: msg.eventId,
|
||||||
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
value: JSON.stringify(msg),
|
||||||
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
},
|
||||||
|
],
|
||||||
const messageValue = {
|
});
|
||||||
eventId: event.eventId,
|
|
||||||
eventType: event.eventType,
|
this.logger.log(
|
||||||
occurredAt: event.occurredAt.toISOString(),
|
`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`,
|
||||||
aggregateId: (event as any).aggregateId || '',
|
);
|
||||||
aggregateType: (event as any).aggregateType || 'UserAccount',
|
} else {
|
||||||
payload,
|
// 从领域事件发布
|
||||||
};
|
const event = eventOrTopic;
|
||||||
|
const topic = this.getTopicForEvent(event);
|
||||||
await this.producer.send({
|
const payload = (event as any).payload;
|
||||||
topic,
|
|
||||||
messages: [
|
this.logger.log(
|
||||||
{
|
`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`,
|
||||||
key: event.eventId,
|
);
|
||||||
value: JSON.stringify(messageValue),
|
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
||||||
},
|
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
||||||
],
|
|
||||||
});
|
const messageValue = {
|
||||||
|
eventId: event.eventId,
|
||||||
this.logger.log(`[PUBLISH] Successfully published ${event.eventType} to ${topic}`);
|
eventType: event.eventType,
|
||||||
}
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
}
|
aggregateId: (event as any).aggregateId || '',
|
||||||
|
aggregateType: (event as any).aggregateType || 'UserAccount',
|
||||||
/**
|
payload,
|
||||||
* 根据事件类型获取对应的 Kafka topic
|
};
|
||||||
* MPC 相关事件发送到 mpc.* topic,其他事件发送到 identity.* topic
|
|
||||||
*/
|
await this.producer.send({
|
||||||
private getTopicForEvent(event: DomainEvent): string {
|
topic,
|
||||||
const eventType = event.eventType;
|
messages: [
|
||||||
|
{
|
||||||
// MPC 相关事件使用 mpc.* 前缀
|
key: event.eventId,
|
||||||
if (eventType === 'MpcKeygenRequested') {
|
value: JSON.stringify(messageValue),
|
||||||
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED;
|
},
|
||||||
}
|
],
|
||||||
if (eventType === 'MpcSigningRequested') {
|
});
|
||||||
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
|
|
||||||
}
|
this.logger.log(
|
||||||
|
`[PUBLISH] Successfully published ${event.eventType} to ${topic}`,
|
||||||
// 其他事件使用 identity.* 前缀
|
);
|
||||||
return `identity.${eventType}`;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishAll(events: DomainEvent[]): Promise<void> {
|
/**
|
||||||
for (const event of events) {
|
* 根据事件类型获取对应的 Kafka topic
|
||||||
await this.publish(event);
|
* MPC 相关事件发送到 mpc.* topic,其他事件发送到 identity.* topic
|
||||||
}
|
*/
|
||||||
}
|
private getTopicForEvent(event: DomainEvent): string {
|
||||||
}
|
const eventType = event.eventType;
|
||||||
|
|
||||||
|
// MPC 相关事件使用 mpc.* 前缀
|
||||||
|
if (eventType === 'MpcKeygenRequested') {
|
||||||
|
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED;
|
||||||
|
}
|
||||||
|
if (eventType === 'MpcSigningRequested') {
|
||||||
|
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他事件使用 identity.* 前缀
|
||||||
|
return `identity.${eventType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishAll(events: DomainEvent[]): Promise<void> {
|
||||||
|
for (const event of events) {
|
||||||
|
await this.publish(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,94 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { EventPublisherService } from './event-publisher.service';
|
import { EventPublisherService } from './event-publisher.service';
|
||||||
import { DeadLetterService } from './dead-letter.service';
|
import { DeadLetterService } from './dead-letter.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventRetryService {
|
export class EventRetryService {
|
||||||
private readonly logger = new Logger(EventRetryService.name);
|
private readonly logger = new Logger(EventRetryService.name);
|
||||||
private readonly maxRetries = 3;
|
private readonly maxRetries = 3;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
private readonly deadLetterService: DeadLetterService,
|
private readonly deadLetterService: DeadLetterService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// 可以通过 API 手动触发或由外部调度器调用
|
// 可以通过 API 手动触发或由外部调度器调用
|
||||||
async retryFailedEvents(): Promise<void> {
|
async retryFailedEvents(): Promise<void> {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
this.logger.debug('Retry job already running, skipping');
|
this.logger.debug('Retry job already running, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.logger.log('Starting failed events retry job');
|
this.logger.log('Starting failed events retry job');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const failedEvents = await this.deadLetterService.getFailedEvents(50);
|
const failedEvents = await this.deadLetterService.getFailedEvents(50);
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const event of failedEvents) {
|
for (const event of failedEvents) {
|
||||||
if (event.retryCount >= this.maxRetries) {
|
if (event.retryCount >= this.maxRetries) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
|
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.eventPublisher.publish(event.topic, {
|
await this.eventPublisher.publish(event.topic, {
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
occurredAt: event.createdAt.toISOString(),
|
occurredAt: event.createdAt.toISOString(),
|
||||||
aggregateId: event.aggregateId,
|
aggregateId: event.aggregateId,
|
||||||
aggregateType: event.aggregateType,
|
aggregateType: event.aggregateType,
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
payload: event.payload,
|
payload: event.payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.deadLetterService.markAsProcessed(event.id);
|
await this.deadLetterService.markAsProcessed(event.id);
|
||||||
successCount++;
|
successCount++;
|
||||||
this.logger.log(`Successfully retried event: ${event.eventId}`);
|
this.logger.log(`Successfully retried event: ${event.eventId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failCount++;
|
failCount++;
|
||||||
await this.deadLetterService.incrementRetryCount(event.id);
|
await this.deadLetterService.incrementRetryCount(event.id);
|
||||||
this.logger.error(`Failed to retry event: ${event.eventId}`, error);
|
this.logger.error(`Failed to retry event: ${event.eventId}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Finished retry job: ${successCount} succeeded, ${failCount} failed`,
|
`Finished retry job: ${successCount} succeeded, ${failCount} failed`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async manualRetry(eventId: string): Promise<boolean> {
|
async manualRetry(eventId: string): Promise<boolean> {
|
||||||
const events = await this.deadLetterService.getFailedEvents(1000);
|
const events = await this.deadLetterService.getFailedEvents(1000);
|
||||||
const event = events.find((e) => e.eventId === eventId);
|
const event = events.find((e) => e.eventId === eventId);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
this.logger.warn(`Event not found: ${eventId}`);
|
this.logger.warn(`Event not found: ${eventId}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.eventPublisher.publish(event.topic, {
|
await this.eventPublisher.publish(event.topic, {
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
occurredAt: event.createdAt.toISOString(),
|
occurredAt: event.createdAt.toISOString(),
|
||||||
aggregateId: event.aggregateId,
|
aggregateId: event.aggregateId,
|
||||||
aggregateType: event.aggregateType,
|
aggregateType: event.aggregateType,
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
payload: event.payload,
|
payload: event.payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.deadLetterService.markAsProcessed(event.id);
|
await this.deadLetterService.markAsProcessed(event.id);
|
||||||
this.logger.log(`Manually retried event: ${eventId}`);
|
this.logger.log(`Manually retried event: ${eventId}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to manually retry event: ${eventId}`, error);
|
this.logger.error(`Failed to manually retry event: ${eventId}`, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export * from './kafka.module';
|
export * from './kafka.module';
|
||||||
export * from './event-publisher.service';
|
export * from './event-publisher.service';
|
||||||
export * from './event-consumer.controller';
|
export * from './event-consumer.controller';
|
||||||
export * from './dead-letter.service';
|
export * from './dead-letter.service';
|
||||||
export * from './event-retry.service';
|
export * from './event-retry.service';
|
||||||
export * from './mpc-event-consumer.service';
|
export * from './mpc-event-consumer.service';
|
||||||
export * from './blockchain-event-consumer.service';
|
export * from './blockchain-event-consumer.service';
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EventPublisherService } from './event-publisher.service';
|
import { EventPublisherService } from './event-publisher.service';
|
||||||
import { MpcEventConsumerService } from './mpc-event-consumer.service';
|
import { MpcEventConsumerService } from './mpc-event-consumer.service';
|
||||||
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
|
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
|
||||||
import { OutboxPublisherService } from './outbox-publisher.service';
|
import { OutboxPublisherService } from './outbox-publisher.service';
|
||||||
import { OutboxRepository } from '../persistence/repositories/outbox.repository';
|
import { OutboxRepository } from '../persistence/repositories/outbox.repository';
|
||||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
OutboxPublisherService,
|
OutboxPublisherService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
OutboxPublisherService,
|
OutboxPublisherService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class KafkaModule {}
|
export class KafkaModule {}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
* Updates user wallet addresses when keygen completes.
|
* Updates user wallet addresses when keygen completes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||||
|
|
||||||
|
|
@ -32,7 +37,7 @@ export interface KeygenCompletedPayload {
|
||||||
threshold: string;
|
threshold: string;
|
||||||
extraPayload?: {
|
extraPayload?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||||
username: string;
|
username: string;
|
||||||
delegateShare?: {
|
delegateShare?: {
|
||||||
partyId: string;
|
partyId: string;
|
||||||
|
|
@ -85,15 +90,20 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
const brokers = this.configService
|
||||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
.get<string>('KAFKA_BROKERS')
|
||||||
|
?.split(',') || ['localhost:9092'];
|
||||||
|
const clientId =
|
||||||
|
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||||
const groupId = 'identity-service-mpc-events';
|
const groupId = 'identity-service-mpc-events';
|
||||||
|
|
||||||
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
|
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
|
||||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`);
|
this.logger.log(
|
||||||
|
`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||||
this.kafka = new Kafka({
|
this.kafka = new Kafka({
|
||||||
|
|
@ -101,10 +111,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
brokers,
|
brokers,
|
||||||
logLevel: logLevel.WARN,
|
logLevel: logLevel.WARN,
|
||||||
retry: {
|
retry: {
|
||||||
initialRetryTime: 1000, // 1 秒
|
initialRetryTime: 1000, // 1 秒
|
||||||
maxRetryTime: 300000, // 最大 5 分钟
|
maxRetryTime: 300000, // 最大 5 分钟
|
||||||
retries: 15, // 最多 15 次
|
retries: 15, // 最多 15 次
|
||||||
multiplier: 2, // 指数退避因子
|
multiplier: 2, // 指数退避因子
|
||||||
restartOnFailure: async () => true,
|
restartOnFailure: async () => true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -119,16 +129,26 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
||||||
await this.consumer.connect();
|
await this.consumer.connect();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
|
this.logger.log(
|
||||||
|
`[CONNECT] MPC Event Kafka consumer connected successfully`,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to MPC topics
|
// Subscribe to MPC topics
|
||||||
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
|
await this.consumer.subscribe({
|
||||||
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
|
topics: Object.values(MPC_TOPICS),
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Start consuming
|
// Start consuming
|
||||||
await this.startConsuming();
|
await this.startConsuming();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Failed to connect MPC Event Kafka consumer`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,9 +189,15 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|
||||||
private async startConsuming(): Promise<void> {
|
private async startConsuming(): Promise<void> {
|
||||||
await this.consumer.run({
|
await this.consumer.run({
|
||||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
eachMessage: async ({
|
||||||
|
topic,
|
||||||
|
partition,
|
||||||
|
message,
|
||||||
|
}: EachMessagePayload) => {
|
||||||
const offset = message.offset;
|
const offset = message.offset;
|
||||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = message.value?.toString();
|
const value = message.value?.toString();
|
||||||
|
|
@ -180,55 +206,83 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
this.logger.log(
|
||||||
|
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const payload = parsed.payload || parsed;
|
const payload = parsed.payload || parsed;
|
||||||
|
|
||||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`);
|
this.logger.log(
|
||||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
switch (topic) {
|
switch (topic) {
|
||||||
case MPC_TOPICS.KEYGEN_STARTED:
|
case MPC_TOPICS.KEYGEN_STARTED:
|
||||||
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
|
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
|
||||||
if (this.keygenStartedHandler) {
|
if (this.keygenStartedHandler) {
|
||||||
await this.keygenStartedHandler(payload as KeygenStartedPayload);
|
await this.keygenStartedHandler(
|
||||||
|
payload as KeygenStartedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
|
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for KeygenStarted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for KeygenStarted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.KEYGEN_COMPLETED:
|
case MPC_TOPICS.KEYGEN_COMPLETED:
|
||||||
this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
|
this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
|
||||||
this.logger.log(`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`);
|
this.logger.log(
|
||||||
|
`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`,
|
||||||
|
);
|
||||||
if (this.keygenCompletedHandler) {
|
if (this.keygenCompletedHandler) {
|
||||||
await this.keygenCompletedHandler(payload as KeygenCompletedPayload);
|
await this.keygenCompletedHandler(
|
||||||
|
payload as KeygenCompletedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
|
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for KeygenCompleted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.SIGNING_COMPLETED:
|
case MPC_TOPICS.SIGNING_COMPLETED:
|
||||||
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
|
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
|
||||||
if (this.signingCompletedHandler) {
|
if (this.signingCompletedHandler) {
|
||||||
await this.signingCompletedHandler(payload as SigningCompletedPayload);
|
await this.signingCompletedHandler(
|
||||||
|
payload as SigningCompletedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
|
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for SigningCompleted`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for SigningCompleted`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MPC_TOPICS.SESSION_FAILED:
|
case MPC_TOPICS.SESSION_FAILED:
|
||||||
this.logger.log(`[HANDLE] Processing SessionFailed event`);
|
this.logger.log(`[HANDLE] Processing SessionFailed event`);
|
||||||
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`);
|
this.logger.log(
|
||||||
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`);
|
`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`,
|
||||||
|
);
|
||||||
if (this.sessionFailedHandler) {
|
if (this.sessionFailedHandler) {
|
||||||
await this.sessionFailedHandler(payload as SessionFailedPayload);
|
await this.sessionFailedHandler(
|
||||||
|
payload as SessionFailedPayload,
|
||||||
|
);
|
||||||
this.logger.log(`[HANDLE] SessionFailed handler completed`);
|
this.logger.log(`[HANDLE] SessionFailed handler completed`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`);
|
this.logger.warn(
|
||||||
|
`[HANDLE] No handler registered for SessionFailed`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -236,7 +290,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
|
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing MPC event from ${topic}`, error);
|
this.logger.error(
|
||||||
|
`[ERROR] Error processing MPC event from ${topic}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Re-throw to trigger Kafka retry mechanism
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
// This ensures messages are not marked as consumed until successfully processed
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue