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 { UserAccountController } from './controllers/user-account.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { ReferralsController } from './controllers/referrals.controller';
|
||||
import { TotpController } from './controllers/totp.controller';
|
||||
import { ApplicationModule } from '@/application/application.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [UserAccountController, AuthController, ReferralsController, TotpController],
|
||||
})
|
||||
export class ApiModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserAccountController } from './controllers/user-account.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { ReferralsController } from './controllers/referrals.controller';
|
||||
import { TotpController } from './controllers/totp.controller';
|
||||
import { ApplicationModule } from '@/application/application.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [
|
||||
UserAccountController,
|
||||
AuthController,
|
||||
ReferralsController,
|
||||
TotpController,
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,102 @@
|
|||
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { Public } from '@/shared/guards/jwt-auth.guard';
|
||||
import { AutoLoginCommand } from '@/application/commands';
|
||||
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly userService: UserApplicationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Token刷新' })
|
||||
async refresh(@Body() dto: AutoLoginDto) {
|
||||
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
|
||||
@ApiResponse({ status: 200, type: AdminLoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
|
||||
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
|
||||
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
|
||||
|
||||
// 从数据库查找管理员
|
||||
const admin = await this.prisma.adminAccount.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
|
||||
throw new UnauthorizedException('邮箱或密码错误');
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (admin.status !== 'ACTIVE') {
|
||||
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`);
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
|
||||
// 验证密码 (使用 bcrypt)
|
||||
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
||||
throw new UnauthorizedException('邮箱或密码错误');
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
await this.prisma.adminAccount.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
// 生成 JWT Token
|
||||
const payload = {
|
||||
sub: admin.id.toString(),
|
||||
email: admin.email,
|
||||
role: admin.role,
|
||||
type: 'admin',
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||
|
||||
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
|
||||
|
||||
return {
|
||||
userId: admin.id.toString(),
|
||||
email: admin.email,
|
||||
nickname: admin.nickname,
|
||||
role: admin.role,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { Public } from '@/shared/guards/jwt-auth.guard';
|
||||
import { AutoLoginCommand } from '@/application/commands';
|
||||
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly userService: UserApplicationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Token刷新' })
|
||||
async refresh(@Body() dto: AutoLoginDto) {
|
||||
return this.userService.autoLogin(
|
||||
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
|
||||
@ApiResponse({ status: 200, type: AdminLoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
|
||||
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
|
||||
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
|
||||
|
||||
// 从数据库查找管理员
|
||||
const admin = await this.prisma.adminAccount.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
|
||||
throw new UnauthorizedException('邮箱或密码错误');
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (admin.status !== 'ACTIVE') {
|
||||
this.logger.warn(
|
||||
`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`,
|
||||
);
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
|
||||
// 验证密码 (使用 bcrypt)
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
dto.password,
|
||||
admin.passwordHash,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
||||
throw new UnauthorizedException('邮箱或密码错误');
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
await this.prisma.adminAccount.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
// 生成 JWT Token
|
||||
const payload = {
|
||||
sub: admin.id.toString(),
|
||||
email: admin.email,
|
||||
role: admin.role,
|
||||
type: 'admin',
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||
|
||||
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
|
||||
|
||||
return {
|
||||
userId: admin.id.toString(),
|
||||
email: admin.email,
|
||||
nickname: admin.nickname,
|
||||
role: admin.role,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '@/shared/decorators/public.decorator';
|
||||
|
||||
@ApiTags('健康检查')
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: '健康检查端点' })
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'identity-service',
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '@/shared/decorators/public.decorator';
|
||||
|
||||
@ApiTags('健康检查')
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: '健康检查端点' })
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'identity-service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,104 @@
|
|||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
||||
import {
|
||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
|
||||
ReferralLinkResponseDto, ReferralStatsResponseDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('Referrals')
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReferralsController {
|
||||
constructor(private readonly userService: UserApplicationService) {}
|
||||
|
||||
/**
|
||||
* GET /api/me - 获取当前登录用户信息 + 推荐码
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
|
||||
@ApiResponse({ status: 200, type: MeResponseDto })
|
||||
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
||||
return this.userService.getMe(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/validate - 校验推荐码是否合法
|
||||
*/
|
||||
@Public()
|
||||
@Get('referrals/validate')
|
||||
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
|
||||
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
||||
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
||||
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
|
||||
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/referrals/links - 为当前登录用户生成短链/渠道链接
|
||||
*/
|
||||
@Post('referrals/links')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
|
||||
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
||||
async generateReferralLink(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: GenerateReferralLinkDto,
|
||||
): Promise<ReferralLinkResponseDto> {
|
||||
return this.userService.generateReferralLink(
|
||||
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/stats - 查询登录用户的邀请记录
|
||||
*/
|
||||
@Get('referrals/stats')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
|
||||
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
||||
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
|
||||
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
|
||||
}
|
||||
}
|
||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import {
|
||||
JwtAuthGuard,
|
||||
Public,
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '@/shared/guards/jwt-auth.guard';
|
||||
import {
|
||||
ValidateReferralCodeQuery,
|
||||
GetReferralStatsQuery,
|
||||
GenerateReferralLinkCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
GenerateReferralLinkDto,
|
||||
MeResponseDto,
|
||||
ReferralValidationResponseDto,
|
||||
ReferralLinkResponseDto,
|
||||
ReferralStatsResponseDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('Referrals')
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReferralsController {
|
||||
constructor(private readonly userService: UserApplicationService) {}
|
||||
|
||||
/**
|
||||
* GET /api/me - 获取当前登录用户信息 + 推荐码
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取当前登录用户信息',
|
||||
description: '返回用户基本信息、推荐码和推荐链接',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: MeResponseDto })
|
||||
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
||||
return this.userService.getMe(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/validate - 校验推荐码是否合法
|
||||
*/
|
||||
@Public()
|
||||
@Get('referrals/validate')
|
||||
@ApiOperation({
|
||||
summary: '校验推荐码',
|
||||
description: '创建账号时校验推荐码是否合法',
|
||||
})
|
||||
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
||||
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
||||
async validateReferralCode(
|
||||
@Query('code') code: string,
|
||||
): Promise<ReferralValidationResponseDto> {
|
||||
return this.userService.validateReferralCode(
|
||||
new ValidateReferralCodeQuery(code),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/referrals/links - 为当前登录用户生成短链/渠道链接
|
||||
*/
|
||||
@Post('referrals/links')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '生成推荐链接',
|
||||
description: '为当前登录用户生成短链/渠道链接',
|
||||
})
|
||||
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
||||
async generateReferralLink(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: GenerateReferralLinkDto,
|
||||
): Promise<ReferralLinkResponseDto> {
|
||||
return this.userService.generateReferralLink(
|
||||
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/stats - 查询登录用户的邀请记录
|
||||
*/
|
||||
@Get('referrals/stats')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '查询邀请统计',
|
||||
description: '查询登录用户的邀请记录和统计数据',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
||||
async getReferralStats(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<ReferralStatsResponseDto> {
|
||||
return this.userService.getReferralStats(
|
||||
new GetReferralStatsQuery(user.userId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { TotpService } from '@/application/services/totp.service';
|
||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
|
@ -36,21 +41,34 @@ export class TotpController {
|
|||
constructor(private readonly totpService: TotpService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: '获取 TOTP 状态', description: '查询当前用户的 TOTP 启用状态' })
|
||||
@ApiOperation({
|
||||
summary: '获取 TOTP 状态',
|
||||
description: '查询当前用户的 TOTP 启用状态',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
|
||||
async getStatus(@CurrentUser() user: CurrentUserPayload): Promise<TotpStatusResponseDto> {
|
||||
async getStatus(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
): Promise<TotpStatusResponseDto> {
|
||||
return this.totpService.getTotpStatus(BigInt(user.userId));
|
||||
}
|
||||
|
||||
@Post('setup')
|
||||
@ApiOperation({ summary: '设置 TOTP', description: '生成 TOTP 密钥,返回二维码和手动输入密钥' })
|
||||
@ApiOperation({
|
||||
summary: '设置 TOTP',
|
||||
description: '生成 TOTP 密钥,返回二维码和手动输入密钥',
|
||||
})
|
||||
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
|
||||
async setup(@CurrentUser() user: CurrentUserPayload): Promise<SetupTotpResponseDto> {
|
||||
async setup(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
): Promise<SetupTotpResponseDto> {
|
||||
return this.totpService.setupTotp(BigInt(user.userId));
|
||||
}
|
||||
|
||||
@Post('enable')
|
||||
@ApiOperation({ summary: '启用 TOTP', description: '验证码正确后启用 TOTP 二次验证' })
|
||||
@ApiOperation({
|
||||
summary: '启用 TOTP',
|
||||
description: '验证码正确后启用 TOTP 二次验证',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
|
||||
async enable(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
|
|
@ -61,7 +79,10 @@ export class TotpController {
|
|||
}
|
||||
|
||||
@Post('disable')
|
||||
@ApiOperation({ summary: '禁用 TOTP', description: '验证码正确后禁用 TOTP 二次验证' })
|
||||
@ApiOperation({
|
||||
summary: '禁用 TOTP',
|
||||
description: '验证码正确后禁用 TOTP 二次验证',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
|
||||
async disable(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
|
|
@ -72,13 +93,19 @@ export class TotpController {
|
|||
}
|
||||
|
||||
@Post('verify')
|
||||
@ApiOperation({ summary: '验证 TOTP', description: '验证 TOTP 验证码是否正确' })
|
||||
@ApiOperation({
|
||||
summary: '验证 TOTP',
|
||||
description: '验证 TOTP 验证码是否正确',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '验证结果' })
|
||||
async verify(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: VerifyTotpDto,
|
||||
): Promise<{ valid: boolean }> {
|
||||
const valid = await this.totpService.verifyTotp(BigInt(user.userId), dto.code);
|
||||
const valid = await this.totpService.verifyTotp(
|
||||
BigInt(user.userId),
|
||||
dto.code,
|
||||
);
|
||||
return { valid };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,404 +1,578 @@
|
|||
import {
|
||||
Controller, Post, Get, Put, Body, Param, UseGuards, Headers,
|
||||
UseInterceptors, UploadedFile, BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
||||
import {
|
||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
|
||||
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
|
||||
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
||||
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
|
||||
BindWalletDto, SubmitKYCDto, RemoveDeviceDto, RevokeMnemonicDto,
|
||||
FreezeAccountDto, UnfreezeAccountDto, RequestKeyRotationDto,
|
||||
GenerateBackupCodesDto, RecoverByBackupCodeDto,
|
||||
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
|
||||
UserProfileResponseDto, DeviceResponseDto,
|
||||
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
|
||||
VerifySmsCodeDto, SetPasswordDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('User')
|
||||
@Controller('user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserAccountController {
|
||||
constructor(
|
||||
private readonly userService: UserApplicationService,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('auto-create')
|
||||
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
|
||||
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
|
||||
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
||||
return this.userService.autoCreateAccount(
|
||||
new AutoCreateAccountCommand(
|
||||
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-mnemonic')
|
||||
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
||||
return this.userService.recoverByMnemonic(
|
||||
new RecoverByMnemonicCommand(
|
||||
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-phone')
|
||||
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
||||
return this.userService.recoverByPhone(
|
||||
new RecoverByPhoneCommand(
|
||||
dto.accountSequence, dto.phoneNumber, dto.smsCode,
|
||||
dto.newDeviceId, dto.deviceName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auto-login')
|
||||
@ApiOperation({ summary: '自动登录(Token刷新)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async autoLogin(@Body() dto: AutoLoginDto) {
|
||||
return this.userService.autoLogin(
|
||||
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-sms-code')
|
||||
@ApiOperation({ summary: '发送短信验证码' })
|
||||
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type));
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify-sms-code')
|
||||
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' })
|
||||
@ApiResponse({ status: 200, description: '验证成功' })
|
||||
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
||||
await this.userService.verifySmsCode(
|
||||
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'),
|
||||
);
|
||||
return { message: '验证成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册(手机号)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.userService.register(
|
||||
new RegisterCommand(
|
||||
dto.phoneNumber, dto.smsCode, dto.deviceId,
|
||||
dto.deviceName, dto.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '用户登录(手机号)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.userService.login(
|
||||
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bind-phone')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '绑定手机号' })
|
||||
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
|
||||
await this.userService.bindPhoneNumber(
|
||||
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
||||
);
|
||||
return { message: '绑定成功' };
|
||||
}
|
||||
|
||||
@Post('set-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' })
|
||||
@ApiResponse({ status: 200, description: '密码设置成功' })
|
||||
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) {
|
||||
await this.userService.setPassword(
|
||||
new SetPasswordCommand(user.userId, dto.password),
|
||||
);
|
||||
return { message: '密码设置成功' };
|
||||
}
|
||||
|
||||
@Get('my-profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询我的资料' })
|
||||
@ApiResponse({ status: 200, type: UserProfileResponseDto })
|
||||
async getMyProfile(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
|
||||
}
|
||||
|
||||
@Put('update-profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '更新用户资料' })
|
||||
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
|
||||
await this.userService.updateProfile(
|
||||
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
||||
);
|
||||
return { message: '更新成功' };
|
||||
}
|
||||
|
||||
@Post('submit-kyc')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '提交KYC认证' })
|
||||
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
|
||||
await this.userService.submitKYC(
|
||||
new SubmitKYCCommand(
|
||||
user.userId, dto.realName, dto.idCardNumber,
|
||||
dto.idCardFrontUrl, dto.idCardBackUrl,
|
||||
),
|
||||
);
|
||||
return { message: '提交成功' };
|
||||
}
|
||||
|
||||
@Get('my-devices')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查看我的设备列表' })
|
||||
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
||||
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId));
|
||||
}
|
||||
|
||||
@Post('remove-device')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '移除设备' })
|
||||
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
|
||||
await this.userService.removeDevice(
|
||||
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
||||
);
|
||||
return { message: '移除成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('by-referral-code/:code')
|
||||
@ApiOperation({ summary: '根据推荐码查询用户' })
|
||||
async getByReferralCode(@Param('code') code: string) {
|
||||
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
|
||||
}
|
||||
|
||||
@Get('wallet')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
||||
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto })
|
||||
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto })
|
||||
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getWalletStatus(
|
||||
new GetWalletStatusQuery(user.accountSequence),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('wallet/retry')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '手动重试钱包生成', description: '当钱包生成失败或超时时,用户可手动触发重试' })
|
||||
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
||||
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.retryWalletGeneration(user.userId);
|
||||
return { message: '钱包生成重试已触发,请稍后查询钱包状态' };
|
||||
}
|
||||
|
||||
@Put('mnemonic/backup')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '标记助记词已备份' })
|
||||
@ApiResponse({ status: 200, description: '标记成功' })
|
||||
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.markMnemonicBackedUp(
|
||||
new MarkMnemonicBackedUpCommand(user.userId),
|
||||
);
|
||||
return { message: '已标记为已备份' };
|
||||
}
|
||||
|
||||
@Post('mnemonic/revoke')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '挂失助记词', description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复' })
|
||||
@ApiResponse({ status: 200, description: '挂失结果' })
|
||||
async revokeMnemonic(@CurrentUser() user: CurrentUserData, @Body() dto: RevokeMnemonicDto) {
|
||||
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('freeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '冻结账户', description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作' })
|
||||
@ApiResponse({ status: 200, description: '冻结结果' })
|
||||
async freezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: FreezeAccountDto) {
|
||||
return this.userService.freezeAccount(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('unfreeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '解冻账户', description: '验证身份后解冻账户,支持助记词或手机号验证' })
|
||||
@ApiResponse({ status: 200, description: '解冻结果' })
|
||||
async unfreezeAccount(@CurrentUser() user: CurrentUserData, @Body() dto: UnfreezeAccountDto) {
|
||||
return this.userService.unfreezeAccount({
|
||||
userId: user.userId,
|
||||
verifyMethod: dto.verifyMethod,
|
||||
mnemonic: dto.mnemonic,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
smsCode: dto.smsCode,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('key-rotation/request')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '请求密钥轮换', description: '验证当前助记词后,请求轮换 MPC 密钥对' })
|
||||
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
||||
async requestKeyRotation(@CurrentUser() user: CurrentUserData, @Body() dto: RequestKeyRotationDto) {
|
||||
return this.userService.requestKeyRotation({
|
||||
userId: user.userId,
|
||||
currentMnemonic: dto.currentMnemonic,
|
||||
reason: dto.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('backup-codes/generate')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '生成恢复码', description: '验证助记词后生成一组一次性恢复码' })
|
||||
@ApiResponse({ status: 200, description: '恢复码列表' })
|
||||
async generateBackupCodes(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateBackupCodesDto) {
|
||||
return this.userService.generateBackupCodes({
|
||||
userId: user.userId,
|
||||
mnemonic: dto.mnemonic,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-backup-code')
|
||||
@ApiOperation({ summary: '使用恢复码恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
|
||||
return this.userService.recoverByBackupCode({
|
||||
accountSequence: dto.accountSequence,
|
||||
backupCode: dto.backupCode,
|
||||
newDeviceId: dto.newDeviceId,
|
||||
deviceName: dto.deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('sms/send-withdraw-code')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' })
|
||||
@ApiResponse({ status: 200, description: '发送成功' })
|
||||
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.sendWithdrawSmsCode(user.userId);
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
@Post('sms/verify-withdraw-code')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' })
|
||||
@ApiResponse({ status: 200, description: '验证结果' })
|
||||
async verifyWithdrawSmsCode(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { code: string },
|
||||
) {
|
||||
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code);
|
||||
return { valid };
|
||||
}
|
||||
|
||||
@Post('verify-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' })
|
||||
@ApiResponse({ status: 200, description: '验证结果' })
|
||||
async verifyPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { password: string },
|
||||
) {
|
||||
const valid = await this.userService.verifyPassword(user.userId, body.password);
|
||||
return { valid };
|
||||
}
|
||||
|
||||
@Get('users/resolve-address/:accountSequence')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' })
|
||||
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
||||
@ApiResponse({ status: 404, description: '找不到用户' })
|
||||
async resolveAccountSequenceToAddress(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
) {
|
||||
// 默认返回 KAVA 链地址(支持所有 EVM 链)
|
||||
const address = await this.userService.resolveAccountSequenceToAddress(
|
||||
accountSequence,
|
||||
'KAVA',
|
||||
);
|
||||
return { address };
|
||||
}
|
||||
|
||||
@Post('upload-avatar')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '上传用户头像' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '上传成功,返回头像URL' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
// 验证文件是否存在
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的图片');
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!this.storageService.isValidImageType(file.mimetype)) {
|
||||
throw new BadRequestException('不支持的图片格式,请使用 jpg, png, gif 或 webp');
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > this.storageService.maxAvatarSize) {
|
||||
throw new BadRequestException('图片大小不能超过 5MB');
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const result = await this.storageService.uploadAvatar(
|
||||
user.userId,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
// 更新用户头像URL
|
||||
await this.userService.updateProfile(
|
||||
new UpdateProfileCommand(user.userId, undefined, result.url),
|
||||
);
|
||||
|
||||
return {
|
||||
message: '上传成功',
|
||||
avatarUrl: result.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Headers,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||
import {
|
||||
JwtAuthGuard,
|
||||
Public,
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '@/shared/guards/jwt-auth.guard';
|
||||
import {
|
||||
AutoCreateAccountCommand,
|
||||
RecoverByMnemonicCommand,
|
||||
RecoverByPhoneCommand,
|
||||
AutoLoginCommand,
|
||||
RegisterCommand,
|
||||
LoginCommand,
|
||||
BindPhoneNumberCommand,
|
||||
UpdateProfileCommand,
|
||||
SubmitKYCCommand,
|
||||
RemoveDeviceCommand,
|
||||
SendSmsCodeCommand,
|
||||
GetMyProfileQuery,
|
||||
GetMyDevicesQuery,
|
||||
GetUserByReferralCodeQuery,
|
||||
GetWalletStatusQuery,
|
||||
MarkMnemonicBackedUpCommand,
|
||||
VerifySmsCodeCommand,
|
||||
SetPasswordCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
AutoCreateAccountDto,
|
||||
RecoverByMnemonicDto,
|
||||
RecoverByPhoneDto,
|
||||
AutoLoginDto,
|
||||
SendSmsCodeDto,
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
BindPhoneDto,
|
||||
UpdateProfileDto,
|
||||
BindWalletDto,
|
||||
SubmitKYCDto,
|
||||
RemoveDeviceDto,
|
||||
RevokeMnemonicDto,
|
||||
FreezeAccountDto,
|
||||
UnfreezeAccountDto,
|
||||
RequestKeyRotationDto,
|
||||
GenerateBackupCodesDto,
|
||||
RecoverByBackupCodeDto,
|
||||
AutoCreateAccountResponseDto,
|
||||
RecoverAccountResponseDto,
|
||||
LoginResponseDto,
|
||||
UserProfileResponseDto,
|
||||
DeviceResponseDto,
|
||||
WalletStatusReadyResponseDto,
|
||||
WalletStatusGeneratingResponseDto,
|
||||
VerifySmsCodeDto,
|
||||
SetPasswordDto,
|
||||
LoginWithPasswordDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('User')
|
||||
@Controller('user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserAccountController {
|
||||
constructor(
|
||||
private readonly userService: UserApplicationService,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('auto-create')
|
||||
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
|
||||
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
|
||||
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
||||
return this.userService.autoCreateAccount(
|
||||
new AutoCreateAccountCommand(
|
||||
dto.deviceId,
|
||||
dto.deviceName,
|
||||
dto.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-mnemonic')
|
||||
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
||||
return this.userService.recoverByMnemonic(
|
||||
new RecoverByMnemonicCommand(
|
||||
dto.accountSequence,
|
||||
dto.mnemonic,
|
||||
dto.newDeviceId,
|
||||
dto.deviceName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-phone')
|
||||
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
||||
return this.userService.recoverByPhone(
|
||||
new RecoverByPhoneCommand(
|
||||
dto.accountSequence,
|
||||
dto.phoneNumber,
|
||||
dto.smsCode,
|
||||
dto.newDeviceId,
|
||||
dto.deviceName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auto-login')
|
||||
@ApiOperation({ summary: '自动登录(Token刷新)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async autoLogin(@Body() dto: AutoLoginDto) {
|
||||
return this.userService.autoLogin(
|
||||
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-sms-code')
|
||||
@ApiOperation({ summary: '发送短信验证码' })
|
||||
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||
await this.userService.sendSmsCode(
|
||||
new SendSmsCodeCommand(dto.phoneNumber, dto.type),
|
||||
);
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify-sms-code')
|
||||
@ApiOperation({
|
||||
summary: '验证短信验证码',
|
||||
description: '仅验证验证码是否正确,不进行登录或注册',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '验证成功' })
|
||||
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
|
||||
await this.userService.verifySmsCode(
|
||||
new VerifySmsCodeCommand(
|
||||
dto.phoneNumber,
|
||||
dto.smsCode,
|
||||
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
),
|
||||
);
|
||||
return { message: '验证成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册(手机号)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.userService.register(
|
||||
new RegisterCommand(
|
||||
dto.phoneNumber,
|
||||
dto.smsCode,
|
||||
dto.deviceId,
|
||||
dto.deviceName,
|
||||
dto.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '用户登录(手机号+短信验证码)' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.userService.login(
|
||||
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login-with-password')
|
||||
@ApiOperation({
|
||||
summary: '用户登录(手机号+密码)',
|
||||
description: '用于账号恢复,使用手机号和密码登录',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
async loginWithPassword(@Body() dto: LoginWithPasswordDto) {
|
||||
return this.userService.loginWithPassword(
|
||||
dto.phoneNumber,
|
||||
dto.password,
|
||||
dto.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bind-phone')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '绑定手机号' })
|
||||
async bindPhone(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: BindPhoneDto,
|
||||
) {
|
||||
await this.userService.bindPhoneNumber(
|
||||
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
||||
);
|
||||
return { message: '绑定成功' };
|
||||
}
|
||||
|
||||
@Post('set-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '设置登录密码',
|
||||
description: '首次设置或修改登录密码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '密码设置成功' })
|
||||
async setPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: SetPasswordDto,
|
||||
) {
|
||||
await this.userService.setPassword(
|
||||
new SetPasswordCommand(user.userId, dto.password),
|
||||
);
|
||||
return { message: '密码设置成功' };
|
||||
}
|
||||
|
||||
@Get('my-profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询我的资料' })
|
||||
@ApiResponse({ status: 200, type: UserProfileResponseDto })
|
||||
async getMyProfile(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
|
||||
}
|
||||
|
||||
@Put('update-profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '更新用户资料' })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
) {
|
||||
await this.userService.updateProfile(
|
||||
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl),
|
||||
);
|
||||
return { message: '更新成功' };
|
||||
}
|
||||
|
||||
@Post('submit-kyc')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '提交KYC认证' })
|
||||
async submitKYC(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: SubmitKYCDto,
|
||||
) {
|
||||
await this.userService.submitKYC(
|
||||
new SubmitKYCCommand(
|
||||
user.userId,
|
||||
dto.realName,
|
||||
dto.idCardNumber,
|
||||
dto.idCardFrontUrl,
|
||||
dto.idCardBackUrl,
|
||||
),
|
||||
);
|
||||
return { message: '提交成功' };
|
||||
}
|
||||
|
||||
@Get('my-devices')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查看我的设备列表' })
|
||||
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
||||
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getMyDevices(
|
||||
new GetMyDevicesQuery(user.userId, user.deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('remove-device')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '移除设备' })
|
||||
async removeDevice(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: RemoveDeviceDto,
|
||||
) {
|
||||
await this.userService.removeDevice(
|
||||
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
||||
);
|
||||
return { message: '移除成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('by-referral-code/:code')
|
||||
@ApiOperation({ summary: '根据推荐码查询用户' })
|
||||
async getByReferralCode(@Param('code') code: string) {
|
||||
return this.userService.getUserByReferralCode(
|
||||
new GetUserByReferralCodeQuery(code),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('wallet')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取我的钱包状态和地址' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '钱包已就绪',
|
||||
type: WalletStatusReadyResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 202,
|
||||
description: '钱包生成中',
|
||||
type: WalletStatusGeneratingResponseDto,
|
||||
})
|
||||
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
|
||||
return this.userService.getWalletStatus(
|
||||
new GetWalletStatusQuery(user.accountSequence),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('wallet/retry')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '手动重试钱包生成',
|
||||
description: '当钱包生成失败或超时时,用户可手动触发重试',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '重试请求已提交' })
|
||||
async retryWalletGeneration(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.retryWalletGeneration(user.userId);
|
||||
return { message: '钱包生成重试已触发,请稍后查询钱包状态' };
|
||||
}
|
||||
|
||||
@Put('mnemonic/backup')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '标记助记词已备份' })
|
||||
@ApiResponse({ status: 200, description: '标记成功' })
|
||||
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.markMnemonicBackedUp(
|
||||
new MarkMnemonicBackedUpCommand(user.userId),
|
||||
);
|
||||
return { message: '已标记为已备份' };
|
||||
}
|
||||
|
||||
@Post('mnemonic/revoke')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '挂失助记词',
|
||||
description: '用户主动挂失助记词,挂失后该助记词将无法用于账户恢复',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '挂失结果' })
|
||||
async revokeMnemonic(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: RevokeMnemonicDto,
|
||||
) {
|
||||
return this.userService.revokeMnemonic(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('freeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '冻结账户',
|
||||
description: '用户主动冻结自己的账户,冻结后账户将无法进行任何操作',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '冻结结果' })
|
||||
async freezeAccount(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: FreezeAccountDto,
|
||||
) {
|
||||
return this.userService.freezeAccount(user.userId, dto.reason);
|
||||
}
|
||||
|
||||
@Post('unfreeze')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '解冻账户',
|
||||
description: '验证身份后解冻账户,支持助记词或手机号验证',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '解冻结果' })
|
||||
async unfreezeAccount(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: UnfreezeAccountDto,
|
||||
) {
|
||||
return this.userService.unfreezeAccount({
|
||||
userId: user.userId,
|
||||
verifyMethod: dto.verifyMethod,
|
||||
mnemonic: dto.mnemonic,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
smsCode: dto.smsCode,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('key-rotation/request')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '请求密钥轮换',
|
||||
description: '验证当前助记词后,请求轮换 MPC 密钥对',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '轮换请求结果' })
|
||||
async requestKeyRotation(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: RequestKeyRotationDto,
|
||||
) {
|
||||
return this.userService.requestKeyRotation({
|
||||
userId: user.userId,
|
||||
currentMnemonic: dto.currentMnemonic,
|
||||
reason: dto.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('backup-codes/generate')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '生成恢复码',
|
||||
description: '验证助记词后生成一组一次性恢复码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '恢复码列表' })
|
||||
async generateBackupCodes(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: GenerateBackupCodesDto,
|
||||
) {
|
||||
return this.userService.generateBackupCodes({
|
||||
userId: user.userId,
|
||||
mnemonic: dto.mnemonic,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('recover-by-backup-code')
|
||||
@ApiOperation({ summary: '使用恢复码恢复账户' })
|
||||
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||
async recoverByBackupCode(@Body() dto: RecoverByBackupCodeDto) {
|
||||
return this.userService.recoverByBackupCode({
|
||||
accountSequence: dto.accountSequence,
|
||||
backupCode: dto.backupCode,
|
||||
newDeviceId: dto.newDeviceId,
|
||||
deviceName: dto.deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('sms/send-withdraw-code')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '发送提取验证短信',
|
||||
description: '向用户绑定的手机号发送提取验证码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '发送成功' })
|
||||
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.sendWithdrawSmsCode(user.userId);
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
@Post('sms/verify-withdraw-code')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '验证提取短信验证码',
|
||||
description: '验证提取操作的短信验证码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '验证结果' })
|
||||
async verifyWithdrawSmsCode(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { code: string },
|
||||
) {
|
||||
const valid = await this.userService.verifyWithdrawSmsCode(
|
||||
user.userId,
|
||||
body.code,
|
||||
);
|
||||
return { valid };
|
||||
}
|
||||
|
||||
@Post('verify-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '验证登录密码',
|
||||
description: '验证用户的登录密码,用于敏感操作二次验证',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '验证结果' })
|
||||
async verifyPassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: { password: string },
|
||||
) {
|
||||
const valid = await this.userService.verifyPassword(
|
||||
user.userId,
|
||||
body.password,
|
||||
);
|
||||
return { valid };
|
||||
}
|
||||
|
||||
@Get('users/resolve-address/:accountSequence')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '解析充值ID到区块链地址',
|
||||
description: '通过用户的 accountSequence 获取其区块链钱包地址',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '返回区块链地址' })
|
||||
@ApiResponse({ status: 404, description: '找不到用户' })
|
||||
async resolveAccountSequenceToAddress(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
) {
|
||||
// 默认返回 KAVA 链地址(支持所有 EVM 链)
|
||||
const address = await this.userService.resolveAccountSequenceToAddress(
|
||||
accountSequence,
|
||||
'KAVA',
|
||||
);
|
||||
return { address };
|
||||
}
|
||||
|
||||
@Post('upload-avatar')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '上传用户头像' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '头像图片文件 (支持 jpg, png, gif, webp, 最大5MB)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '上传成功,返回头像URL' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
// 验证文件是否存在
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的图片');
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!this.storageService.isValidImageType(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
'不支持的图片格式,请使用 jpg, png, gif 或 webp',
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > this.storageService.maxAvatarSize) {
|
||||
throw new BadRequestException('图片大小不能超过 5MB');
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const result = await this.storageService.uploadAvatar(
|
||||
user.userId,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
// 更新用户头像URL
|
||||
await this.userService.updateProfile(
|
||||
new UpdateProfileCommand(user.userId, undefined, result.url),
|
||||
);
|
||||
|
||||
return {
|
||||
message: '上传成功',
|
||||
avatarUrl: result.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,367 +1,398 @@
|
|||
// Request DTOs
|
||||
export * from './request';
|
||||
|
||||
// Response DTOs
|
||||
export * from './response';
|
||||
|
||||
// 其他通用DTOs
|
||||
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class AutoLoginDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export class SendSmsCodeDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inviterReferralCode?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export class AdminLoginDto {
|
||||
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', description: '密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class AdminLoginResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '管理员邮箱' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '管理员昵称' })
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ description: '角色' })
|
||||
role: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export class BindWalletDto {
|
||||
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
|
||||
@IsEnum(['KAVA', 'DST', 'BSC'])
|
||||
chainType: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class RemoveDeviceDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
// Response DTOs
|
||||
export class AutoCreateAccountResponseDto {
|
||||
@ApiProperty({ example: 'D2512110001', description: '用户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
userSerialNum: string;
|
||||
|
||||
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
|
||||
avatarSvg: string;
|
||||
|
||||
@ApiProperty({ description: '访问令牌' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class RecoverAccountResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 钱包地址响应
|
||||
export class WalletAddressesDto {
|
||||
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
|
||||
kava: string;
|
||||
|
||||
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
|
||||
dst: string;
|
||||
|
||||
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
|
||||
bsc: string;
|
||||
}
|
||||
|
||||
// 钱包状态响应 (就绪)
|
||||
export class WalletStatusReadyResponseDto {
|
||||
@ApiProperty({ example: 'ready', description: '钱包状态' })
|
||||
status: 'ready';
|
||||
|
||||
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
||||
walletAddresses: WalletAddressesDto;
|
||||
|
||||
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
// 钱包状态响应 (生成中)
|
||||
export class WalletStatusGeneratingResponseDto {
|
||||
@ApiProperty({ example: 'generating', description: '钱包状态' })
|
||||
status: 'generating';
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// ============ Referral DTOs ============
|
||||
|
||||
export class GenerateReferralLinkDto {
|
||||
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '活动ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
campaignId?: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty({ description: '推荐码' })
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '完整推荐链接' })
|
||||
referralLink: string;
|
||||
|
||||
@ApiProperty({ example: 'D2512110001', description: '推荐人序列号', nullable: true })
|
||||
inviterSequence: string | null;
|
||||
|
||||
@ApiProperty({ description: '钱包地址列表' })
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
|
||||
@ApiProperty()
|
||||
kycStatus: string;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class ReferralValidationResponseDto {
|
||||
@ApiProperty({ description: '推荐码是否有效' })
|
||||
valid: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
referralCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邀请人信息' })
|
||||
inviterInfo?: {
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ReferralLinkResponseDto {
|
||||
@ApiProperty()
|
||||
linkId: string;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '短链' })
|
||||
shortUrl: string;
|
||||
|
||||
@ApiProperty({ description: '完整链接' })
|
||||
fullUrl: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
channel: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
campaignId: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class InviteRecordDto {
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
|
||||
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
|
||||
level: number;
|
||||
}
|
||||
|
||||
export class ReferralStatsResponseDto {
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '总邀请人数' })
|
||||
totalInvites: number;
|
||||
|
||||
@ApiProperty({ description: '直接邀请人数' })
|
||||
directInvites: number;
|
||||
|
||||
@ApiProperty({ description: '间接邀请人数 (二级)' })
|
||||
indirectInvites: number;
|
||||
|
||||
@ApiProperty({ description: '今日邀请' })
|
||||
todayInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本周邀请' })
|
||||
thisWeekInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本月邀请' })
|
||||
thisMonthInvites: number;
|
||||
|
||||
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
|
||||
recentInvites: InviteRecordDto[];
|
||||
}
|
||||
// Request DTOs
|
||||
export * from './request';
|
||||
|
||||
// Response DTOs
|
||||
export * from './response';
|
||||
|
||||
// 其他通用DTOs
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNotEmpty,
|
||||
Matches,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class AutoLoginDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export class SendSmsCodeDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inviterReferralCode?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export class AdminLoginDto {
|
||||
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', description: '密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class AdminLoginResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '管理员邮箱' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '管理员昵称' })
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ description: '角色' })
|
||||
role: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export class BindWalletDto {
|
||||
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
|
||||
@IsEnum(['KAVA', 'DST', 'BSC'])
|
||||
chainType: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class RemoveDeviceDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
// Response DTOs
|
||||
export class AutoCreateAccountResponseDto {
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '用户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
userSerialNum: string;
|
||||
|
||||
@ApiProperty({ example: 'ABC123', description: '推荐码' })
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
|
||||
avatarSvg: string;
|
||||
|
||||
@ApiProperty({ description: '访问令牌' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class RecoverAccountResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 钱包地址响应
|
||||
export class WalletAddressesDto {
|
||||
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
|
||||
kava: string;
|
||||
|
||||
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
|
||||
dst: string;
|
||||
|
||||
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
|
||||
bsc: string;
|
||||
}
|
||||
|
||||
// 钱包状态响应 (就绪)
|
||||
export class WalletStatusReadyResponseDto {
|
||||
@ApiProperty({ example: 'ready', description: '钱包状态' })
|
||||
status: 'ready';
|
||||
|
||||
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
|
||||
walletAddresses: WalletAddressesDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'word1 word2 ... word12',
|
||||
description: '助记词 (12词)',
|
||||
})
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
// 钱包状态响应 (生成中)
|
||||
export class WalletStatusGeneratingResponseDto {
|
||||
@ApiProperty({ example: 'generating', description: '钱包状态' })
|
||||
status: 'generating';
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// ============ Referral DTOs ============
|
||||
|
||||
export class GenerateReferralLinkDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '渠道标识: wechat, telegram, twitter 等',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '活动ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
campaignId?: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty({ description: '推荐码' })
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '完整推荐链接' })
|
||||
referralLink: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '推荐人序列号',
|
||||
nullable: true,
|
||||
})
|
||||
inviterSequence: string | null;
|
||||
|
||||
@ApiProperty({ description: '钱包地址列表' })
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
|
||||
@ApiProperty()
|
||||
kycStatus: string;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class ReferralValidationResponseDto {
|
||||
@ApiProperty({ description: '推荐码是否有效' })
|
||||
valid: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
referralCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邀请人信息' })
|
||||
inviterInfo?: {
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ReferralLinkResponseDto {
|
||||
@ApiProperty()
|
||||
linkId: string;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '短链' })
|
||||
shortUrl: string;
|
||||
|
||||
@ApiProperty({ description: '完整链接' })
|
||||
fullUrl: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
channel: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
campaignId: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class InviteRecordDto {
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
|
||||
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
|
||||
level: number;
|
||||
}
|
||||
|
||||
export class ReferralStatsResponseDto {
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '总邀请人数' })
|
||||
totalInvites: number;
|
||||
|
||||
@ApiProperty({ description: '直接邀请人数' })
|
||||
directInvites: number;
|
||||
|
||||
@ApiProperty({ description: '间接邀请人数 (二级)' })
|
||||
indirectInvites: number;
|
||||
|
||||
@ApiProperty({ description: '今日邀请' })
|
||||
todayInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本周邀请' })
|
||||
thisWeekInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本月邀请' })
|
||||
thisMonthInvites: number;
|
||||
|
||||
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
|
||||
recentInvites: InviteRecordDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,53 @@
|
|||
import { IsString, IsOptional, IsNotEmpty, Matches, IsObject } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 设备信息 - 接收任意 JSON 对象
|
||||
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
||||
*/
|
||||
export interface DeviceNameDto {
|
||||
model?: string; // 设备型号
|
||||
platform?: string; // 平台: ios, android, web
|
||||
osVersion?: string; // 系统版本
|
||||
brand?: string; // 品牌
|
||||
manufacturer?: string; // 厂商
|
||||
device?: string; // 设备名
|
||||
product?: string; // 产品名
|
||||
hardware?: string; // 硬件名
|
||||
sdkInt?: number; // SDK 版本 (Android)
|
||||
isPhysicalDevice?: boolean; // 是否真机
|
||||
[key: string]: unknown; // 允许其他字段
|
||||
}
|
||||
|
||||
export class AutoCreateAccountDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '设备信息 (JSON 对象)',
|
||||
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
deviceName?: DeviceNameDto;
|
||||
|
||||
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
||||
inviterReferralCode?: string;
|
||||
}
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNotEmpty,
|
||||
Matches,
|
||||
IsObject,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 设备信息 - 接收任意 JSON 对象
|
||||
* 前端可以传递完整的设备硬件信息,后端只提取需要的字段存储
|
||||
*/
|
||||
export interface DeviceNameDto {
|
||||
model?: string; // 设备型号
|
||||
platform?: string; // 平台: ios, android, web
|
||||
osVersion?: string; // 系统版本
|
||||
brand?: string; // 品牌
|
||||
manufacturer?: string; // 厂商
|
||||
device?: string; // 设备名
|
||||
product?: string; // 产品名
|
||||
hardware?: string; // 硬件名
|
||||
sdkInt?: number; // SDK 版本 (Android)
|
||||
isPhysicalDevice?: boolean; // 是否真机
|
||||
[key: string]: unknown; // 允许其他字段
|
||||
}
|
||||
|
||||
export class AutoCreateAccountDto {
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
description: '设备唯一标识',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '设备信息 (JSON 对象)',
|
||||
example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' },
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
deviceName?: DeviceNameDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'RWAABC1234',
|
||||
description: '邀请人推荐码 (6-20位大写字母和数字)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
|
||||
inviterReferralCode?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { IsString, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BindPhoneDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
}
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BindPhoneDto {
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty } from 'class-validator';
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GenerateBackupCodesDto {
|
||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
||||
@ApiProperty({
|
||||
example: 'abandon abandon ...',
|
||||
description: '当前助记词(验证身份用)',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||
mnemonic: string;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
export * from './auto-create-account.dto';
|
||||
export * from './recover-by-mnemonic.dto';
|
||||
export * from './recover-by-phone.dto';
|
||||
export * from './bind-phone.dto';
|
||||
export * from './submit-kyc.dto';
|
||||
export * from './revoke-mnemonic.dto';
|
||||
export * from './freeze-account.dto';
|
||||
export * from './unfreeze-account.dto';
|
||||
export * from './request-key-rotation.dto';
|
||||
export * from './generate-backup-codes.dto';
|
||||
export * from './recover-by-backup-code.dto';
|
||||
export * from './verify-sms-code.dto';
|
||||
export * from './set-password.dto';
|
||||
export * from './auto-create-account.dto';
|
||||
export * from './recover-by-mnemonic.dto';
|
||||
export * from './recover-by-phone.dto';
|
||||
export * from './bind-phone.dto';
|
||||
export * from './submit-kyc.dto';
|
||||
export * from './revoke-mnemonic.dto';
|
||||
export * from './freeze-account.dto';
|
||||
export * from './unfreeze-account.dto';
|
||||
export * from './request-key-rotation.dto';
|
||||
export * from './generate-backup-codes.dto';
|
||||
export * from './recover-by-backup-code.dto';
|
||||
export * from './verify-sms-code.dto';
|
||||
export * from './set-password.dto';
|
||||
export * from './login-with-password.dto';
|
||||
|
|
|
|||
|
|
@ -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: '恢复码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供恢复码' })
|
||||
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, { message: '恢复码格式不正确' })
|
||||
@Matches(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, {
|
||||
message: '恢复码格式不正确',
|
||||
})
|
||||
backupCode: string;
|
||||
|
||||
@ApiProperty({ example: 'device-uuid-123', description: '新设备ID' })
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RecoverByMnemonicDto {
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
@IsString()
|
||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mnemonic: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newDeviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
}
|
||||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RecoverByMnemonicDto {
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(/^D\d{11}$/, {
|
||||
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({
|
||||
example:
|
||||
'abandon ability able about above absent absorb abstract absurd abuse access accident',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mnemonic: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newDeviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RecoverByPhoneDto {
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
@IsString()
|
||||
@Matches(/^D\d{11}$/, { message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newDeviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
}
|
||||
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RecoverByPhoneDto {
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(/^D\d{11}$/, {
|
||||
message: '账户序列号格式错误,应为 D + 年月日(6位) + 序号(5位)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ example: '13800138000' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newDeviceId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequestKeyRotationDto {
|
||||
@ApiProperty({ example: 'abandon abandon ...', description: '当前助记词(验证身份用)' })
|
||||
@ApiProperty({
|
||||
example: 'abandon abandon ...',
|
||||
description: '当前助记词(验证身份用)',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请提供当前助记词' })
|
||||
currentMnemonic: string;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SubmitKycDto {
|
||||
@ApiProperty({ example: '张三' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
realName: string;
|
||||
|
||||
@ApiProperty({ example: '110101199001011234' })
|
||||
@IsString()
|
||||
@Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/, { message: '身份证号格式错误' })
|
||||
idCardNumber: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idCardFrontUrl: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idCardBackUrl: string;
|
||||
}
|
||||
|
||||
// 导出别名以兼容不同命名风格
|
||||
export { SubmitKycDto as SubmitKYCDto };
|
||||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SubmitKycDto {
|
||||
@ApiProperty({ example: '张三' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
realName: string;
|
||||
|
||||
@ApiProperty({ example: '110101199001011234' })
|
||||
@IsString()
|
||||
@Matches(
|
||||
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/,
|
||||
{ message: '身份证号格式错误' },
|
||||
)
|
||||
idCardNumber: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idCardFrontUrl: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idCardBackUrl: string;
|
||||
}
|
||||
|
||||
// 导出别名以兼容不同命名风格
|
||||
export { SubmitKycDto as SubmitKYCDto };
|
||||
|
|
|
|||
|
|
@ -2,22 +2,34 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UnfreezeAccountDto {
|
||||
@ApiProperty({ example: '确认账户安全', description: '解冻验证方式: mnemonic 或 phone' })
|
||||
@ApiProperty({
|
||||
example: '确认账户安全',
|
||||
description: '解冻验证方式: mnemonic 或 phone',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
|
||||
@ApiPropertyOptional({ example: 'abandon abandon ...', description: '助记词 (verifyMethod=mnemonic时必填)' })
|
||||
@ApiPropertyOptional({
|
||||
example: 'abandon abandon ...',
|
||||
description: '助记词 (verifyMethod=mnemonic时必填)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mnemonic?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+8613800138000', description: '手机号 (verifyMethod=phone时必填)' })
|
||||
@ApiPropertyOptional({
|
||||
example: '+8613800138000',
|
||||
description: '手机号 (verifyMethod=phone时必填)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '123456', description: '短信验证码 (verifyMethod=phone时必填)' })
|
||||
@ApiPropertyOptional({
|
||||
example: '123456',
|
||||
description: '短信验证码 (verifyMethod=phone时必填)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
smsCode?: string;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export class VerifySmsCodeDto {
|
|||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], { message: '无效的验证码类型' })
|
||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
|
||||
message: '无效的验证码类型',
|
||||
})
|
||||
type: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DeviceDto {
|
||||
@ApiProperty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiProperty()
|
||||
deviceName: string;
|
||||
|
||||
@ApiProperty()
|
||||
addedAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
lastActiveAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
// 导出别名以兼容其他命名方式
|
||||
export { DeviceDto as DeviceResponseDto };
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DeviceDto {
|
||||
@ApiProperty()
|
||||
deviceId: string;
|
||||
|
||||
@ApiProperty()
|
||||
deviceName: string;
|
||||
|
||||
@ApiProperty()
|
||||
addedAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
lastActiveAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
// 导出别名以兼容其他命名方式
|
||||
export { DeviceDto as DeviceResponseDto };
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './user-profile.dto';
|
||||
export * from './device.dto';
|
||||
export * from './user-profile.dto';
|
||||
export * from './device.dto';
|
||||
|
|
|
|||
|
|
@ -1,58 +1,61 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class WalletAddressDto {
|
||||
@ApiProperty()
|
||||
chainType: string;
|
||||
|
||||
@ApiProperty()
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class KycInfoDto {
|
||||
@ApiProperty()
|
||||
realName: string;
|
||||
|
||||
@ApiProperty()
|
||||
idCardNumber: string;
|
||||
}
|
||||
|
||||
export class UserProfileDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ example: 'D2512110001', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' })
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ type: [WalletAddressDto] })
|
||||
walletAddresses: WalletAddressDto[];
|
||||
|
||||
@ApiProperty()
|
||||
kycStatus: string;
|
||||
|
||||
@ApiProperty({ type: KycInfoDto, nullable: true })
|
||||
kycInfo: KycInfoDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
|
||||
// 导出别名以兼容其他命名方式
|
||||
export { UserProfileDto as UserProfileResponseDto };
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class WalletAddressDto {
|
||||
@ApiProperty()
|
||||
chainType: string;
|
||||
|
||||
@ApiProperty()
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class KycInfoDto {
|
||||
@ApiProperty()
|
||||
realName: string;
|
||||
|
||||
@ApiProperty()
|
||||
idCardNumber: string;
|
||||
}
|
||||
|
||||
export class UserProfileDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'D2512110001',
|
||||
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
|
||||
})
|
||||
accountSequence: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ type: [WalletAddressDto] })
|
||||
walletAddresses: WalletAddressDto[];
|
||||
|
||||
@ApiProperty()
|
||||
kycStatus: string;
|
||||
|
||||
@ApiProperty({ type: KycInfoDto, nullable: true })
|
||||
kycInfo: KycInfoDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
|
||||
// 导出别名以兼容其他命名方式
|
||||
export { UserProfileDto as UserProfileResponseDto };
|
||||
|
|
|
|||
|
|
@ -1,47 +1,55 @@
|
|||
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
||||
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(phone: string, args: ValidationArguments): boolean {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return '手机号格式错误';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsChinesePhoneConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
||||
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
||||
validate(idCard: string, args: ValidationArguments): boolean {
|
||||
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return '身份证号格式错误';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsChineseIdCardConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
|
||||
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(phone: string, args: ValidationArguments): boolean {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return '手机号格式错误';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsChinesePhone(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsChinesePhoneConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
|
||||
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
|
||||
validate(idCard: string, args: ValidationArguments): boolean {
|
||||
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||
idCard,
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return '身份证号格式错误';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsChineseIdCardConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,155 +1,186 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||
|
||||
// Config
|
||||
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config';
|
||||
|
||||
// Controllers
|
||||
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
||||
import { HealthController } from '@/api/controllers/health.controller';
|
||||
import { ReferralsController } from '@/api/controllers/referrals.controller';
|
||||
import { AuthController } from '@/api/controllers/auth.controller';
|
||||
import { TotpController } from '@/api/controllers/totp.controller';
|
||||
|
||||
// Application Services
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { TotpService } from '@/application/services/totp.service';
|
||||
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
|
||||
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
|
||||
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
|
||||
|
||||
// Domain Services
|
||||
import {
|
||||
AccountSequenceGeneratorService, UserValidatorService,
|
||||
} from '@/domain/services';
|
||||
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
|
||||
// Infrastructure
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc';
|
||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||
|
||||
// Shared
|
||||
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
||||
// ============ Infrastructure Module ============
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 300000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BlockchainClientService,
|
||||
StorageService,
|
||||
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BlockchainClientService,
|
||||
StorageService,
|
||||
MPC_KEY_SHARE_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
||||
// ============ Domain Module ============
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
providers: [
|
||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
||||
// ============ Application Module ============
|
||||
@Module({
|
||||
imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()],
|
||||
providers: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
|
||||
BlockchainWalletHandler,
|
||||
MpcKeygenCompletedHandler,
|
||||
// Tasks - 定时任务
|
||||
WalletRetryTask,
|
||||
],
|
||||
exports: [UserApplicationService, TokenService, TotpService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
||||
// ============ API Module ============
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [HealthController, UserAccountController, ReferralsController, AuthController, TotpController],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
||||
// ============ App Module ============
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
||||
}),
|
||||
}),
|
||||
InfrastructureModule,
|
||||
DomainModule,
|
||||
ApplicationModule,
|
||||
ApiModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||
|
||||
// Config
|
||||
import {
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
kafkaConfig,
|
||||
smsConfig,
|
||||
walletConfig,
|
||||
} from '@/config';
|
||||
|
||||
// Controllers
|
||||
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
||||
import { HealthController } from '@/api/controllers/health.controller';
|
||||
import { ReferralsController } from '@/api/controllers/referrals.controller';
|
||||
import { AuthController } from '@/api/controllers/auth.controller';
|
||||
import { TotpController } from '@/api/controllers/totp.controller';
|
||||
|
||||
// Application Services
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { TotpService } from '@/application/services/totp.service';
|
||||
import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler';
|
||||
import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler';
|
||||
import { WalletRetryTask } from '@/application/tasks/wallet-retry.task';
|
||||
|
||||
// Domain Services
|
||||
import {
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
} from '@/domain/services';
|
||||
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
|
||||
// Infrastructure
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { MpcEventConsumerService } from '@/infrastructure/kafka/mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from '@/infrastructure/kafka/blockchain-event-consumer.service';
|
||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||
import {
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
} from '@/infrastructure/external/mpc';
|
||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||
|
||||
// Shared
|
||||
import {
|
||||
GlobalExceptionFilter,
|
||||
TransformInterceptor,
|
||||
} from '@/shared/filters/global-exception.filter';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
||||
// ============ Infrastructure Module ============
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 300000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BlockchainClientService,
|
||||
StorageService,
|
||||
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BlockchainClientService,
|
||||
StorageService,
|
||||
MPC_KEY_SHARE_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
||||
// ============ Domain Module ============
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
providers: [
|
||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
||||
// ============ Application Module ============
|
||||
@Module({
|
||||
imports: [DomainModule, InfrastructureModule, ScheduleModule.forRoot()],
|
||||
providers: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
// Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化
|
||||
BlockchainWalletHandler,
|
||||
MpcKeygenCompletedHandler,
|
||||
// Tasks - 定时任务
|
||||
WalletRetryTask,
|
||||
],
|
||||
exports: [UserApplicationService, TokenService, TotpService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
||||
// ============ API Module ============
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [
|
||||
HealthController,
|
||||
UserAccountController,
|
||||
ReferralsController,
|
||||
AuthController,
|
||||
TotpController,
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
||||
// ============ App Module ============
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
kafkaConfig,
|
||||
smsConfig,
|
||||
walletConfig,
|
||||
],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
InfrastructureModule,
|
||||
DomainModule,
|
||||
ApplicationModule,
|
||||
ApiModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserApplicationService } from './services/user-application.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { TotpService } from './services/totp.service';
|
||||
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
|
||||
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
||||
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
|
||||
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
|
||||
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
|
||||
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
|
||||
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
|
||||
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
|
||||
import { DomainModule } from '@/domain/domain.module';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [DomainModule, InfrastructureModule],
|
||||
providers: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
BindPhoneHandler,
|
||||
GetMyProfileHandler,
|
||||
GetMyDevicesHandler,
|
||||
// MPC Event Handlers
|
||||
MpcKeygenCompletedHandler,
|
||||
// Blockchain Event Handlers
|
||||
BlockchainWalletHandler,
|
||||
],
|
||||
exports: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
BindPhoneHandler,
|
||||
GetMyProfileHandler,
|
||||
GetMyDevicesHandler,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserApplicationService } from './services/user-application.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { TotpService } from './services/totp.service';
|
||||
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
|
||||
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
||||
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
|
||||
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
|
||||
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
|
||||
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
|
||||
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
|
||||
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
|
||||
import { DomainModule } from '@/domain/domain.module';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [DomainModule, InfrastructureModule],
|
||||
providers: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
BindPhoneHandler,
|
||||
GetMyProfileHandler,
|
||||
GetMyDevicesHandler,
|
||||
// MPC Event Handlers
|
||||
MpcKeygenCompletedHandler,
|
||||
// Blockchain Event Handlers
|
||||
BlockchainWalletHandler,
|
||||
],
|
||||
exports: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
TotpService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
BindPhoneHandler,
|
||||
GetMyProfileHandler,
|
||||
GetMyDevicesHandler,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { DeviceNameInput } from '../index';
|
||||
|
||||
export class AutoCreateAccountCommand {
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: DeviceNameInput,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
import { DeviceNameInput } from '../index';
|
||||
|
||||
export class AutoCreateAccountCommand {
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: DeviceNameInput,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +1,113 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
|
||||
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { AutoCreateAccountResult } from '../index';
|
||||
import { generateIdentity } from '@/shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class AutoCreateAccountHandler {
|
||||
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
||||
private readonly validatorService: UserValidatorService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
||||
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
||||
|
||||
// 1. 验证设备ID
|
||||
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
|
||||
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
|
||||
|
||||
// 2. 验证邀请码
|
||||
let inviterSequence: AccountSequence | null = null;
|
||||
if (command.inviterReferralCode) {
|
||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
|
||||
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
|
||||
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
||||
inviterSequence = inviter!.accountSequence;
|
||||
}
|
||||
|
||||
// 3. 生成用户序列号
|
||||
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
|
||||
|
||||
// 4. 生成用户名和头像
|
||||
const identity = generateIdentity(accountSequence.value);
|
||||
|
||||
// 5. 构建设备名称,保存完整的设备信息 JSON
|
||||
let deviceNameStr = '未命名设备';
|
||||
if (command.deviceName) {
|
||||
const parts: string[] = [];
|
||||
if (command.deviceName.model) parts.push(command.deviceName.model);
|
||||
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
||||
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
|
||||
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
||||
}
|
||||
|
||||
// 6. 创建账户 - 传递完整的 deviceName JSON
|
||||
const account = UserAccount.createAutomatic({
|
||||
accountSequence,
|
||||
initialDeviceId: command.deviceId,
|
||||
deviceName: deviceNameStr,
|
||||
deviceInfo: command.deviceName, // 100% 保持原样存储
|
||||
inviterSequence,
|
||||
nickname: identity.username,
|
||||
avatarSvg: identity.avatarSvg,
|
||||
});
|
||||
|
||||
// 7. 保存账户
|
||||
await this.userRepository.save(account);
|
||||
|
||||
// 8. 生成 Token
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.deviceId,
|
||||
});
|
||||
|
||||
// 9. 发布领域事件
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`);
|
||||
|
||||
return {
|
||||
userSerialNum: account.accountSequence.value,
|
||||
referralCode: account.referralCode.value,
|
||||
username: account.nickname,
|
||||
avatarSvg: account.avatarUrl || identity.avatarSvg,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import {
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
} from '@/domain/services';
|
||||
import { ReferralCode, AccountSequence } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { AutoCreateAccountResult } from '../index';
|
||||
import { generateIdentity } from '@/shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class AutoCreateAccountHandler {
|
||||
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
||||
private readonly validatorService: UserValidatorService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: AutoCreateAccountCommand,
|
||||
): Promise<AutoCreateAccountResult> {
|
||||
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
||||
|
||||
// 1. 验证设备ID
|
||||
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(
|
||||
command.deviceId,
|
||||
);
|
||||
if (!deviceCheck.isValid)
|
||||
throw new ApplicationError(deviceCheck.errorMessage!);
|
||||
|
||||
// 2. 验证邀请码
|
||||
let inviterSequence: AccountSequence | null = null;
|
||||
if (command.inviterReferralCode) {
|
||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||
const referralValidation =
|
||||
await this.validatorService.validateReferralCode(referralCode);
|
||||
if (!referralValidation.isValid)
|
||||
throw new ApplicationError(referralValidation.errorMessage!);
|
||||
const inviter =
|
||||
await this.userRepository.findByReferralCode(referralCode);
|
||||
inviterSequence = inviter!.accountSequence;
|
||||
}
|
||||
|
||||
// 3. 生成用户序列号
|
||||
const accountSequence =
|
||||
await this.sequenceGenerator.generateNextUserSequence();
|
||||
|
||||
// 4. 生成用户名和头像
|
||||
const identity = generateIdentity(accountSequence.value);
|
||||
|
||||
// 5. 构建设备名称,保存完整的设备信息 JSON
|
||||
let deviceNameStr = '未命名设备';
|
||||
if (command.deviceName) {
|
||||
const parts: string[] = [];
|
||||
if (command.deviceName.model) parts.push(command.deviceName.model);
|
||||
if (command.deviceName.platform) parts.push(command.deviceName.platform);
|
||||
if (command.deviceName.osVersion)
|
||||
parts.push(command.deviceName.osVersion);
|
||||
if (parts.length > 0) deviceNameStr = parts.join(' ');
|
||||
}
|
||||
|
||||
// 6. 创建账户 - 传递完整的 deviceName JSON
|
||||
const account = UserAccount.createAutomatic({
|
||||
accountSequence,
|
||||
initialDeviceId: command.deviceId,
|
||||
deviceName: deviceNameStr,
|
||||
deviceInfo: command.deviceName, // 100% 保持原样存储
|
||||
inviterSequence,
|
||||
nickname: identity.username,
|
||||
avatarSvg: identity.avatarSvg,
|
||||
});
|
||||
|
||||
// 7. 保存账户
|
||||
await this.userRepository.save(account);
|
||||
|
||||
// 8. 生成 Token
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.deviceId,
|
||||
});
|
||||
|
||||
// 9. 发布领域事件
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
this.logger.log(
|
||||
`Account created: sequence=${accountSequence.value}, username=${identity.username}`,
|
||||
);
|
||||
|
||||
return {
|
||||
userSerialNum: account.accountSequence.value,
|
||||
referralCode: account.referralCode.value,
|
||||
username: account.nickname,
|
||||
avatarSvg: account.avatarUrl || identity.avatarSvg,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export class BindPhoneCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
) {}
|
||||
}
|
||||
export class BindPhoneCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { BindPhoneCommand } from './bind-phone.command';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserValidatorService } from '@/domain/services';
|
||||
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
@Injectable()
|
||||
export class BindPhoneHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly validatorService: UserValidatorService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(command: BindPhoneCommand): Promise<void> {
|
||||
const account = await this.userRepository.findById(UserId.create(command.userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`);
|
||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
||||
|
||||
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
|
||||
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!);
|
||||
|
||||
account.bindPhoneNumber(phoneNumber);
|
||||
await this.userRepository.save(account);
|
||||
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { BindPhoneCommand } from './bind-phone.command';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserValidatorService } from '@/domain/services';
|
||||
import { UserId, PhoneNumber } from '@/domain/value-objects';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
@Injectable()
|
||||
export class BindPhoneHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly validatorService: UserValidatorService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(command: BindPhoneCommand): Promise<void> {
|
||||
const account = await this.userRepository.findById(
|
||||
UserId.create(command.userId),
|
||||
);
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||
const cachedCode = await this.redisService.get(
|
||||
`sms:bind:${phoneNumber.value}`,
|
||||
);
|
||||
if (cachedCode !== command.smsCode)
|
||||
throw new ApplicationError('验证码错误或已过期');
|
||||
|
||||
const validation =
|
||||
await this.validatorService.validatePhoneNumber(phoneNumber);
|
||||
if (!validation.isValid)
|
||||
throw new ApplicationError(validation.errorMessage!);
|
||||
|
||||
account.bindPhoneNumber(phoneNumber);
|
||||
await this.userRepository.save(account);
|
||||
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,312 +1,313 @@
|
|||
// ============ Types ============
|
||||
// 设备信息输入 - 100% 保持前端传递的原样存储
|
||||
export interface DeviceNameInput {
|
||||
model?: string; // iPhone 15 Pro, Pixel 8
|
||||
platform?: string; // ios, android, web
|
||||
osVersion?: string; // iOS 17.2, Android 14
|
||||
[key: string]: unknown; // 允许任意其他字段
|
||||
}
|
||||
|
||||
// ============ Commands ============
|
||||
export class AutoCreateAccountCommand {
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: DeviceNameInput,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RecoverByMnemonicCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly mnemonic: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RecoverByPhoneCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class AutoLoginCommand {
|
||||
constructor(
|
||||
public readonly refreshToken: string,
|
||||
public readonly deviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RegisterCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class LoginCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly deviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BindPhoneNumberCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class UpdateProfileCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly nickname?: string,
|
||||
public readonly avatarUrl?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BindWalletAddressCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly chainType: string,
|
||||
public readonly address: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SubmitKYCCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
public readonly idCardFrontUrl: string,
|
||||
public readonly idCardBackUrl: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ReviewKYCCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly approved: boolean,
|
||||
public readonly reason?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RemoveDeviceCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
public readonly deviceIdToRemove: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendSmsCodeCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Queries ============
|
||||
export class GetMyProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class GetMyDevicesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetUserByReferralCodeQuery {
|
||||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class ValidateReferralCodeQuery {
|
||||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class GetReferralStatsQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class GenerateReferralLinkCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
||||
public readonly campaignId?: string, // 活动ID
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetWalletStatusQuery {
|
||||
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
||||
}
|
||||
|
||||
export class MarkMnemonicBackedUpCommand {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class VerifySmsCodeCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SetPasswordCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly password: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Results ============
|
||||
|
||||
// 钱包状态
|
||||
export type WalletStatus = 'generating' | 'ready' | 'failed';
|
||||
|
||||
export interface WalletStatusResult {
|
||||
status: WalletStatus;
|
||||
walletAddresses?: {
|
||||
kava: string;
|
||||
dst: string;
|
||||
bsc: string;
|
||||
};
|
||||
mnemonic?: string; // 助记词 (ready 状态时返回)
|
||||
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
||||
}
|
||||
export interface AutoCreateAccountResult {
|
||||
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
||||
referralCode: string; // 推荐码
|
||||
username: string; // 随机用户名
|
||||
avatarSvg: string; // 随机SVG头像
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RecoverAccountResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AutoLoginResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RegisterResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserProfileDTO {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
kycStatus: string;
|
||||
kycInfo: { realName: string; idCardNumber: string } | null;
|
||||
status: string;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
|
||||
export interface DeviceDTO {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
addedAt: Date;
|
||||
lastActiveAt: Date;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface UserBriefDTO {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ReferralCodeValidationResult {
|
||||
valid: boolean;
|
||||
referralCode?: string;
|
||||
inviterInfo?: {
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ReferralLinkResult {
|
||||
linkId: string;
|
||||
referralCode: string;
|
||||
shortUrl: string;
|
||||
fullUrl: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ReferralStatsResult {
|
||||
referralCode: string;
|
||||
totalInvites: number; // 总邀请人数
|
||||
directInvites: number; // 直接邀请人数
|
||||
indirectInvites: number; // 间接邀请人数 (二级)
|
||||
todayInvites: number; // 今日邀请
|
||||
thisWeekInvites: number; // 本周邀请
|
||||
thisMonthInvites: number; // 本月邀请
|
||||
recentInvites: Array<{ // 最近邀请记录
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
registeredAt: Date;
|
||||
level: number; // 1=直接, 2=间接
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MeResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
referralLink: string; // 完整推荐链接
|
||||
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
kycStatus: string;
|
||||
status: string;
|
||||
registeredAt: Date;
|
||||
}
|
||||
// ============ Types ============
|
||||
// 设备信息输入 - 100% 保持前端传递的原样存储
|
||||
export interface DeviceNameInput {
|
||||
model?: string; // iPhone 15 Pro, Pixel 8
|
||||
platform?: string; // ios, android, web
|
||||
osVersion?: string; // iOS 17.2, Android 14
|
||||
[key: string]: unknown; // 允许任意其他字段
|
||||
}
|
||||
|
||||
// ============ Commands ============
|
||||
export class AutoCreateAccountCommand {
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: DeviceNameInput,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RecoverByMnemonicCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly mnemonic: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RecoverByPhoneCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class AutoLoginCommand {
|
||||
constructor(
|
||||
public readonly refreshToken: string,
|
||||
public readonly deviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RegisterCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
public readonly inviterReferralCode?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class LoginCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly deviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BindPhoneNumberCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class UpdateProfileCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly nickname?: string,
|
||||
public readonly avatarUrl?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BindWalletAddressCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly chainType: string,
|
||||
public readonly address: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SubmitKYCCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
public readonly idCardFrontUrl: string,
|
||||
public readonly idCardBackUrl: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ReviewKYCCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly approved: boolean,
|
||||
public readonly reason?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class RemoveDeviceCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
public readonly deviceIdToRemove: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendSmsCodeCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Queries ============
|
||||
export class GetMyProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class GetMyDevicesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetUserByReferralCodeQuery {
|
||||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class ValidateReferralCodeQuery {
|
||||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class GetReferralStatsQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class GenerateReferralLinkCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
||||
public readonly campaignId?: string, // 活动ID
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetWalletStatusQuery {
|
||||
constructor(public readonly userSerialNum: string) {} // 格式: D + YYMMDD + 5位序号
|
||||
}
|
||||
|
||||
export class MarkMnemonicBackedUpCommand {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class VerifySmsCodeCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SetPasswordCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly password: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Results ============
|
||||
|
||||
// 钱包状态
|
||||
export type WalletStatus = 'generating' | 'ready' | 'failed';
|
||||
|
||||
export interface WalletStatusResult {
|
||||
status: WalletStatus;
|
||||
walletAddresses?: {
|
||||
kava: string;
|
||||
dst: string;
|
||||
bsc: string;
|
||||
};
|
||||
mnemonic?: string; // 助记词 (ready 状态时返回)
|
||||
errorMessage?: string; // 失败原因 (failed 状态时返回)
|
||||
}
|
||||
export interface AutoCreateAccountResult {
|
||||
userSerialNum: string; // 用户序列号 (格式: D + YYMMDD + 5位序号)
|
||||
referralCode: string; // 推荐码
|
||||
username: string; // 随机用户名
|
||||
avatarSvg: string; // 随机SVG头像
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RecoverAccountResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AutoLoginResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RegisterResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserProfileDTO {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
kycStatus: string;
|
||||
kycInfo: { realName: string; idCardNumber: string } | null;
|
||||
status: string;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
|
||||
export interface DeviceDTO {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
addedAt: Date;
|
||||
lastActiveAt: Date;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface UserBriefDTO {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ReferralCodeValidationResult {
|
||||
valid: boolean;
|
||||
referralCode?: string;
|
||||
inviterInfo?: {
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ReferralLinkResult {
|
||||
linkId: string;
|
||||
referralCode: string;
|
||||
shortUrl: string;
|
||||
fullUrl: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ReferralStatsResult {
|
||||
referralCode: string;
|
||||
totalInvites: number; // 总邀请人数
|
||||
directInvites: number; // 直接邀请人数
|
||||
indirectInvites: number; // 间接邀请人数 (二级)
|
||||
todayInvites: number; // 今日邀请
|
||||
thisWeekInvites: number; // 本周邀请
|
||||
thisMonthInvites: number; // 本月邀请
|
||||
recentInvites: Array<{
|
||||
// 最近邀请记录
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
registeredAt: Date;
|
||||
level: number; // 1=直接, 2=间接
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MeResult {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
referralLink: string; // 完整推荐链接
|
||||
inviterSequence: string | null; // 推荐人序列号 (格式: D + YYMMDD + 5位序号)
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
kycStatus: string;
|
||||
status: string;
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export class RecoverByMnemonicCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly mnemonic: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
export class RecoverByMnemonicCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly mnemonic: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,106 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { RecoverAccountResult } from '../index';
|
||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||
|
||||
@Injectable()
|
||||
export class RecoverByMnemonicHandler {
|
||||
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly blockchainClient: BlockchainClientService,
|
||||
) {}
|
||||
|
||||
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
|
||||
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
||||
this.logger.log(`Verifying mnemonic for account ${command.accountSequence}`);
|
||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||
accountSequence: command.accountSequence,
|
||||
mnemonic: command.mnemonic,
|
||||
});
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
this.logger.warn(`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`);
|
||||
throw new ApplicationError(verifyResult.message || '助记词错误');
|
||||
}
|
||||
|
||||
this.logger.log(`Mnemonic verified successfully for account ${command.accountSequence}`);
|
||||
|
||||
// 如果头像为空,重新生成一个
|
||||
let avatarUrl = account.avatarUrl;
|
||||
this.logger.log(`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`);
|
||||
if (avatarUrl) {
|
||||
this.logger.log(`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`);
|
||||
}
|
||||
if (!avatarUrl) {
|
||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
||||
avatarUrl = generateRandomAvatarSvg();
|
||||
account.updateProfile({ avatarUrl });
|
||||
}
|
||||
|
||||
account.addDevice(command.newDeviceId, command.deviceName);
|
||||
account.recordLogin();
|
||||
await this.userRepository.save(account);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.newDeviceId,
|
||||
});
|
||||
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
const result = {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
|
||||
this.logger.log(`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`);
|
||||
this.logger.log(`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { RecoverAccountResult } from '../index';
|
||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||
|
||||
@Injectable()
|
||||
export class RecoverByMnemonicHandler {
|
||||
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly blockchainClient: BlockchainClientService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: RecoverByMnemonicCommand,
|
||||
): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account =
|
||||
await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
|
||||
// 调用 blockchain-service 验证助记词(blockchain-service 内部查询哈希并验证)
|
||||
this.logger.log(
|
||||
`Verifying mnemonic for account ${command.accountSequence}`,
|
||||
);
|
||||
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
|
||||
accountSequence: command.accountSequence,
|
||||
mnemonic: command.mnemonic,
|
||||
});
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
this.logger.warn(
|
||||
`Mnemonic verification failed for account ${command.accountSequence}: ${verifyResult.message}`,
|
||||
);
|
||||
throw new ApplicationError(verifyResult.message || '助记词错误');
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Mnemonic verified successfully for account ${command.accountSequence}`,
|
||||
);
|
||||
|
||||
// 如果头像为空,重新生成一个
|
||||
let avatarUrl = account.avatarUrl;
|
||||
this.logger.log(
|
||||
`Account ${command.accountSequence} avatarUrl from DB: ${avatarUrl ? `长度=${avatarUrl.length}` : 'null'}`,
|
||||
);
|
||||
if (avatarUrl) {
|
||||
this.logger.log(
|
||||
`Account ${command.accountSequence} avatarUrl前50字符: ${avatarUrl.substring(0, 50)}`,
|
||||
);
|
||||
}
|
||||
if (!avatarUrl) {
|
||||
this.logger.log(
|
||||
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||
);
|
||||
avatarUrl = generateRandomAvatarSvg();
|
||||
account.updateProfile({ avatarUrl });
|
||||
}
|
||||
|
||||
account.addDevice(command.newDeviceId, command.deviceName);
|
||||
account.recordLogin();
|
||||
await this.userRepository.save(account);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.newDeviceId,
|
||||
});
|
||||
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
const result = {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`RecoverByMnemonic result - accountSequence: ${result.accountSequence}, nickname: ${result.nickname}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`RecoverByMnemonic result - avatarUrl: ${result.avatarUrl ? `长度=${result.avatarUrl.length}` : 'null'}`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export class RecoverByPhoneCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
export class RecoverByPhoneCommand {
|
||||
constructor(
|
||||
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly newDeviceId: string,
|
||||
public readonly deviceName?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +1,80 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { RecoverAccountResult } from '../index';
|
||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||
|
||||
@Injectable()
|
||||
export class RecoverByPhoneHandler {
|
||||
private readonly logger = new Logger(RecoverByPhoneHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||
|
||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
|
||||
|
||||
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
|
||||
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
||||
|
||||
// 如果头像为空,重新生成一个
|
||||
let avatarUrl = account.avatarUrl;
|
||||
if (!avatarUrl) {
|
||||
this.logger.log(`Account ${command.accountSequence} has no avatar, generating new one`);
|
||||
avatarUrl = generateRandomAvatarSvg();
|
||||
account.updateProfile({ avatarUrl });
|
||||
}
|
||||
|
||||
account.addDevice(command.newDeviceId, command.deviceName);
|
||||
account.recordLogin();
|
||||
await this.userRepository.save(account);
|
||||
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.newDeviceId,
|
||||
});
|
||||
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||
import { TokenService } from '@/application/services/token.service';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { RecoverAccountResult } from '../index';
|
||||
import { generateRandomAvatarSvg } from '@/shared/utils/random-identity.util';
|
||||
|
||||
@Injectable()
|
||||
export class RecoverByPhoneHandler {
|
||||
private readonly logger = new Logger(RecoverByPhoneHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||
const account =
|
||||
await this.userRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||
if (!account.phoneNumber)
|
||||
throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||
|
||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||
if (!account.phoneNumber.equals(phoneNumber))
|
||||
throw new ApplicationError('手机号与账户不匹配');
|
||||
|
||||
const cachedCode = await this.redisService.get(
|
||||
`sms:recover:${phoneNumber.value}`,
|
||||
);
|
||||
if (cachedCode !== command.smsCode)
|
||||
throw new ApplicationError('验证码错误或已过期');
|
||||
|
||||
// 如果头像为空,重新生成一个
|
||||
let avatarUrl = account.avatarUrl;
|
||||
if (!avatarUrl) {
|
||||
this.logger.log(
|
||||
`Account ${command.accountSequence} has no avatar, generating new one`,
|
||||
);
|
||||
avatarUrl = generateRandomAvatarSvg();
|
||||
account.updateProfile({ avatarUrl });
|
||||
}
|
||||
|
||||
account.addDevice(command.newDeviceId, command.deviceName);
|
||||
account.recordLogin();
|
||||
await this.userRepository.save(account);
|
||||
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
deviceId: command.newDeviceId,
|
||||
});
|
||||
|
||||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
*/
|
||||
|
||||
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import { ChainType, UserId } from '@/domain/value-objects';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
|
@ -31,7 +34,7 @@ interface WalletCompletedStatusData {
|
|||
userId: string;
|
||||
publicKey?: string;
|
||||
walletAddresses?: { chainType: string; address: string }[];
|
||||
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
||||
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -49,8 +52,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
|
||||
async onModuleInit() {
|
||||
// Register event handler
|
||||
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this));
|
||||
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events');
|
||||
this.blockchainEventConsumer.onWalletAddressCreated(
|
||||
this.handleWalletAddressCreated.bind(this),
|
||||
);
|
||||
this.logger.log(
|
||||
'[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,21 +68,36 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
* - DST: dst1... (Cosmos bech32)
|
||||
* - BSC: 0x... (EVM)
|
||||
*/
|
||||
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
|
||||
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload;
|
||||
private async handleWalletAddressCreated(
|
||||
payload: WalletAddressCreatedPayload,
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
publicKey,
|
||||
addresses,
|
||||
mnemonic,
|
||||
encryptedMnemonic,
|
||||
mnemonicHash,
|
||||
} = payload;
|
||||
|
||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated: userId=${userId}`);
|
||||
this.logger.log(
|
||||
`[HANDLE] Processing WalletAddressCreated: userId=${userId}`,
|
||||
);
|
||||
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
||||
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
||||
|
||||
if (!userId) {
|
||||
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
|
||||
this.logger.error(
|
||||
'[ERROR] WalletAddressCreated event missing userId, skipping',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addresses || addresses.length === 0) {
|
||||
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping');
|
||||
this.logger.error(
|
||||
'[ERROR] WalletAddressCreated event missing addresses, skipping',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,23 +112,29 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
// 2. Create wallet addresses for each chain (with publicKey)
|
||||
const wallets: WalletAddress[] = addresses.map((addr) => {
|
||||
const chainType = this.parseChainType(addr.chainType);
|
||||
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`);
|
||||
this.logger.log(
|
||||
`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`,
|
||||
);
|
||||
return WalletAddress.create({
|
||||
userId: account.userId,
|
||||
chainType,
|
||||
address: addr.address,
|
||||
publicKey, // 传入公钥,用于关联助记词
|
||||
publicKey, // 传入公钥,用于关联助记词
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Save wallet addresses to user account
|
||||
await this.userRepository.saveWallets(account.userId, wallets);
|
||||
this.logger.log(`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`);
|
||||
this.logger.log(
|
||||
`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`,
|
||||
);
|
||||
|
||||
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
|
||||
// Note: blockchain-service stores mnemonic with accountSequence association
|
||||
if (mnemonic) {
|
||||
this.logger.log(`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`);
|
||||
this.logger.log(
|
||||
`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
||||
|
|
@ -116,7 +144,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
userId,
|
||||
publicKey,
|
||||
walletAddresses: addresses,
|
||||
mnemonic, // 首次返回明文助记词
|
||||
mnemonic, // 首次返回明文助记词
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
@ -128,9 +156,13 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
);
|
||||
|
||||
if (updated) {
|
||||
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen status updated to 'completed' for user: ${userId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`);
|
||||
this.logger.log(
|
||||
`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Log all addresses
|
||||
|
|
@ -138,7 +170,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to process WalletAddressCreated: ${error}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to process WalletAddressCreated: ${error}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
// This ensures messages are not marked as consumed until successfully processed
|
||||
throw error;
|
||||
|
|
@ -158,9 +193,10 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
case 'BSC':
|
||||
return ChainType.BSC;
|
||||
default:
|
||||
this.logger.warn(`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`);
|
||||
this.logger.warn(
|
||||
`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`,
|
||||
);
|
||||
return ChainType.BSC;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ import {
|
|||
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
|
||||
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
|
||||
|
||||
export type KeygenStatus = 'pending' | 'generating' | 'deriving' | 'completed' | 'failed';
|
||||
export type KeygenStatus =
|
||||
| 'pending'
|
||||
| 'generating'
|
||||
| 'deriving'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface KeygenStatusData {
|
||||
status: KeygenStatus;
|
||||
|
|
@ -48,9 +53,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
async onModuleInit() {
|
||||
// Register event handlers
|
||||
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
|
||||
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
|
||||
this.mpcEventConsumer.onKeygenCompleted(
|
||||
this.handleKeygenCompleted.bind(this),
|
||||
);
|
||||
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
|
||||
this.logger.log('[INIT] Registered MPC event handlers (status updates only)');
|
||||
this.logger.log(
|
||||
'[INIT] Registered MPC event handlers (status updates only)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,9 +67,13 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
*
|
||||
* Update Redis status to "generating"
|
||||
*/
|
||||
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> {
|
||||
private async handleKeygenStarted(
|
||||
payload: KeygenStartedPayload,
|
||||
): Promise<void> {
|
||||
const { userId, mpcSessionId } = payload;
|
||||
this.logger.log(`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const statusData: KeygenStatusData = {
|
||||
|
|
@ -76,9 +89,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
KEYGEN_STATUS_TTL,
|
||||
);
|
||||
|
||||
this.logger.log(`[STATUS] Keygen status updated to 'generating' for user: ${userId}`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen status updated to 'generating' for user: ${userId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to update keygen status: ${error}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -94,7 +112,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
* Uses atomic Redis update to ensure status only advances forward:
|
||||
* pending -> generating -> deriving -> completed
|
||||
*/
|
||||
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
|
||||
private async handleKeygenCompleted(
|
||||
payload: KeygenCompletedPayload,
|
||||
): Promise<void> {
|
||||
const { publicKey, extraPayload } = payload;
|
||||
|
||||
if (!extraPayload?.userId) {
|
||||
|
|
@ -103,11 +123,15 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
}
|
||||
|
||||
const { userId, username } = extraPayload;
|
||||
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen completed: userId=${userId}, username=${username}`,
|
||||
);
|
||||
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||
|
||||
try {
|
||||
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
|
||||
this.logger.log(
|
||||
`[STATUS] Waiting for blockchain-service to derive addresses...`,
|
||||
);
|
||||
|
||||
// Update status to "deriving" - waiting for blockchain-service
|
||||
// Uses atomic operation to ensure we don't overwrite higher-priority status
|
||||
|
|
@ -126,13 +150,22 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
);
|
||||
|
||||
if (updated) {
|
||||
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
|
||||
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`);
|
||||
this.logger.log(
|
||||
`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to update keygen status: ${error}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -145,7 +178,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
* 1. Log error
|
||||
* 2. Update Redis status to "failed"
|
||||
*/
|
||||
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
|
||||
private async handleSessionFailed(
|
||||
payload: SessionFailedPayload,
|
||||
): Promise<void> {
|
||||
const { sessionType, errorMessage, extraPayload } = payload;
|
||||
|
||||
// Only handle keygen failures
|
||||
|
|
@ -154,7 +189,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
}
|
||||
|
||||
const userId = extraPayload?.userId || 'unknown';
|
||||
this.logger.error(`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`);
|
||||
this.logger.error(
|
||||
`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Update Redis status to failed
|
||||
|
|
@ -171,9 +208,14 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
|
|||
KEYGEN_STATUS_TTL,
|
||||
);
|
||||
|
||||
this.logger.log(`[STATUS] Keygen status updated to 'failed' for user: ${userId}`);
|
||||
this.logger.log(
|
||||
`[STATUS] Keygen status updated to 'failed' for user: ${userId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to update keygen failed status: ${error}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to update keygen failed status: ${error}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,32 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { GetMyDevicesQuery } from './get-my-devices.query';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { DeviceDTO } from '@/application/commands';
|
||||
|
||||
@Injectable()
|
||||
export class GetMyDevicesHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
return account.getAllDevices().map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.deviceName,
|
||||
addedAt: device.addedAt,
|
||||
lastActiveAt: device.lastActiveAt,
|
||||
isCurrent: device.deviceId === query.currentDeviceId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { GetMyDevicesQuery } from './get-my-devices.query';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { DeviceDTO } from '@/application/commands';
|
||||
|
||||
@Injectable()
|
||||
export class GetMyDevicesHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
|
||||
const account = await this.userRepository.findById(
|
||||
UserId.create(query.userId),
|
||||
);
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
return account.getAllDevices().map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.deviceName,
|
||||
addedAt: device.addedAt,
|
||||
lastActiveAt: device.lastActiveAt,
|
||||
isCurrent: device.deviceId === query.currentDeviceId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export class GetMyDevicesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
) {}
|
||||
}
|
||||
export class GetMyDevicesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly currentDeviceId: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,51 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { GetMyProfileQuery } from './get-my-profile.query';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { UserProfileDTO } from '@/application/commands';
|
||||
|
||||
@Injectable()
|
||||
export class GetMyProfileHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
return this.toDTO(account);
|
||||
}
|
||||
|
||||
private toDTO(account: UserAccount): UserProfileDTO {
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
phoneNumber: account.phoneNumber?.masked() || null,
|
||||
nickname: account.nickname,
|
||||
avatarUrl: account.avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
|
||||
chainType: wa.chainType,
|
||||
address: wa.address,
|
||||
})),
|
||||
kycStatus: account.kycStatus,
|
||||
kycInfo: account.kycInfo
|
||||
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
|
||||
: null,
|
||||
status: account.status,
|
||||
registeredAt: account.registeredAt,
|
||||
lastLoginAt: account.lastLoginAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { GetMyProfileQuery } from './get-my-profile.query';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { UserProfileDTO } from '@/application/commands';
|
||||
|
||||
@Injectable()
|
||||
export class GetMyProfileHandler {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
||||
const account = await this.userRepository.findById(
|
||||
UserId.create(query.userId),
|
||||
);
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
return this.toDTO(account);
|
||||
}
|
||||
|
||||
private toDTO(account: UserAccount): UserProfileDTO {
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
phoneNumber: account.phoneNumber?.masked() || null,
|
||||
nickname: account.nickname,
|
||||
avatarUrl: account.avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
|
||||
chainType: wa.chainType,
|
||||
address: wa.address,
|
||||
})),
|
||||
kycStatus: account.kycStatus,
|
||||
kycInfo: account.kycInfo
|
||||
? {
|
||||
realName: account.kycInfo.realName,
|
||||
idCardNumber: account.kycInfo.maskedIdCardNumber(),
|
||||
}
|
||||
: null,
|
||||
status: account.status,
|
||||
registeredAt: account.registeredAt,
|
||||
lastLoginAt: account.lastLoginAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export class GetMyProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
export class GetMyProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,103 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'crypto';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async generateTokenPair(payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
}): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const accessToken = this.jwtService.sign(
|
||||
{ ...payload, type: 'access' },
|
||||
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
||||
);
|
||||
|
||||
const refreshToken = this.jwtService.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') },
|
||||
);
|
||||
|
||||
// Save refresh token hash
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
await this.prisma.deviceToken.create({
|
||||
data: {
|
||||
userId: BigInt(payload.userId),
|
||||
deviceId: payload.deviceId,
|
||||
refreshTokenHash: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async verifyRefreshToken(token: string): Promise<{
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
deviceId: string;
|
||||
}> {
|
||||
try {
|
||||
const payload = this.jwtService.verify<TokenPayload>(token);
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new ApplicationError('无效的RefreshToken');
|
||||
}
|
||||
|
||||
const tokenHash = this.hashToken(token);
|
||||
const storedToken = await this.prisma.deviceToken.findUnique({
|
||||
where: { refreshTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || storedToken.revokedAt) {
|
||||
throw new ApplicationError('RefreshToken已失效');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.userId,
|
||||
accountSequence: payload.accountSequence,
|
||||
deviceId: payload.deviceId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationError) throw error;
|
||||
throw new ApplicationError('RefreshToken已过期或无效');
|
||||
}
|
||||
}
|
||||
|
||||
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
|
||||
await this.prisma.deviceToken.updateMany({
|
||||
where: { userId: BigInt(userId), deviceId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'crypto';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async generateTokenPair(payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
}): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const accessToken = this.jwtService.sign(
|
||||
{ ...payload, type: 'access' },
|
||||
{
|
||||
expiresIn: this.configService.get<string>(
|
||||
'JWT_ACCESS_EXPIRES_IN',
|
||||
'2h',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const refreshToken = this.jwtService.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{
|
||||
expiresIn: this.configService.get<string>(
|
||||
'JWT_REFRESH_EXPIRES_IN',
|
||||
'30d',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// Save refresh token hash
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
await this.prisma.deviceToken.create({
|
||||
data: {
|
||||
userId: BigInt(payload.userId),
|
||||
deviceId: payload.deviceId,
|
||||
refreshTokenHash: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async verifyRefreshToken(token: string): Promise<{
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
deviceId: string;
|
||||
}> {
|
||||
try {
|
||||
const payload = this.jwtService.verify<TokenPayload>(token);
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new ApplicationError('无效的RefreshToken');
|
||||
}
|
||||
|
||||
const tokenHash = this.hashToken(token);
|
||||
const storedToken = await this.prisma.deviceToken.findUnique({
|
||||
where: { refreshTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || storedToken.revokedAt) {
|
||||
throw new ApplicationError('RefreshToken已失效');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.userId,
|
||||
accountSequence: payload.accountSequence,
|
||||
deviceId: payload.deviceId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationError) throw error;
|
||||
throw new ApplicationError('RefreshToken已过期或无效');
|
||||
}
|
||||
}
|
||||
|
||||
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
|
||||
await this.prisma.deviceToken.updateMany({
|
||||
where: { userId: BigInt(userId), deviceId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ export class TotpService {
|
|||
private readonly logger = new Logger(TotpService.name);
|
||||
|
||||
// TOTP 配置
|
||||
private readonly TOTP_DIGITS = 6; // 验证码位数
|
||||
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
||||
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
||||
private readonly ISSUER = 'RWADurian'; // 应用名称
|
||||
private readonly TOTP_DIGITS = 6; // 验证码位数
|
||||
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
|
||||
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
|
||||
private readonly ISSUER = 'RWADurian'; // 应用名称
|
||||
|
||||
// AES 加密密钥 (生产环境应从环境变量获取)
|
||||
private readonly ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
||||
private readonly ENCRYPTION_KEY =
|
||||
process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
|
|
|
|||
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 { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
|
||||
|
|
@ -64,7 +67,9 @@ export class WalletRetryTask {
|
|||
async handleWalletRetry() {
|
||||
// 防止并发执行
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('[TASK] Previous task still running, skipping this execution');
|
||||
this.logger.warn(
|
||||
'[TASK] Previous task still running, skipping this execution',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +78,12 @@ export class WalletRetryTask {
|
|||
|
||||
try {
|
||||
// 1. 扫描所有 keygen:status:* keys
|
||||
const statusKeys = await this.redisService.keys(`${KEYGEN_STATUS_PREFIX}*`);
|
||||
this.logger.log(`[TASK] Found ${statusKeys.length} wallet generation records`);
|
||||
const statusKeys = await this.redisService.keys(
|
||||
`${KEYGEN_STATUS_PREFIX}*`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[TASK] Found ${statusKeys.length} wallet generation records`,
|
||||
);
|
||||
|
||||
for (const key of statusKeys) {
|
||||
try {
|
||||
|
|
@ -123,7 +132,9 @@ export class WalletRetryTask {
|
|||
// 检查重试限制
|
||||
const canRetry = await this.checkRetryLimit(userId);
|
||||
if (!canRetry) {
|
||||
this.logger.warn(`[TASK] User ${userId} exceeded retry time limit (10 minutes)`);
|
||||
this.logger.warn(
|
||||
`[TASK] User ${userId} exceeded retry time limit (10 minutes)`,
|
||||
);
|
||||
// 更新状态为最终失败
|
||||
await this.markAsFinalFailure(userId);
|
||||
return;
|
||||
|
|
@ -144,19 +155,25 @@ export class WalletRetryTask {
|
|||
|
||||
// 情况1:状态为 failed
|
||||
if (currentStatus === 'failed') {
|
||||
this.logger.log(`[TASK] User ${status.userId} status is 'failed', will retry`);
|
||||
this.logger.log(
|
||||
`[TASK] User ${status.userId} status is 'failed', will retry`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 情况2:状态为 generating 但超过 60 秒
|
||||
if (currentStatus === 'generating' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||
this.logger.log(`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
||||
this.logger.log(
|
||||
`[TASK] User ${status.userId} generating timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 情况3:状态为 deriving 但超过 60 秒
|
||||
if (currentStatus === 'deriving' && elapsed > KEYGEN_TIMEOUT_MS) {
|
||||
this.logger.log(`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`);
|
||||
this.logger.log(
|
||||
`[TASK] User ${status.userId} deriving timeout (${Math.floor(elapsed / 1000)}s), will retry`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +206,9 @@ export class WalletRetryTask {
|
|||
|
||||
// 如果超过 10 分钟,不再重试
|
||||
if (elapsed > MAX_RETRY_DURATION_MS) {
|
||||
this.logger.warn(`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`);
|
||||
this.logger.warn(
|
||||
`[TASK] User ${userId} exceeded max retry duration: ${Math.floor(elapsed / 1000 / 60)} minutes`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +242,9 @@ export class WalletRetryTask {
|
|||
|
||||
await this.eventPublisher.publish(event);
|
||||
|
||||
this.logger.log(`[TASK] Wallet generation retry triggered for user: ${userId}`);
|
||||
this.logger.log(
|
||||
`[TASK] Wallet generation retry triggered for user: ${userId}`,
|
||||
);
|
||||
|
||||
// 4. 更新 Redis 状态为 pending(等待重新生成)
|
||||
const statusData: KeygenStatusData = {
|
||||
|
|
@ -238,7 +259,10 @@ export class WalletRetryTask {
|
|||
60 * 60 * 24, // 24 小时
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`, error);
|
||||
this.logger.error(
|
||||
`[TASK] Failed to retry wallet generation for user ${userId}: ${error}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +298,9 @@ export class WalletRetryTask {
|
|||
60 * 60 * 24, // 24 小时
|
||||
);
|
||||
|
||||
this.logger.log(`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`);
|
||||
this.logger.log(
|
||||
`[TASK] Updated retry record for user ${userId}: count=${record.retryCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -294,6 +320,8 @@ export class WalletRetryTask {
|
|||
60 * 60 * 24, // 24 小时
|
||||
);
|
||||
|
||||
this.logger.error(`[TASK] Marked user ${userId} as final failure after retry timeout`);
|
||||
this.logger.error(
|
||||
`[TASK] Marked user ${userId} as final failure after retry timeout`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const appConfig = () => ({
|
||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||
env: process.env.APP_ENV || 'development',
|
||||
});
|
||||
export const appConfig = () => ({
|
||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||
env: process.env.APP_ENV || 'development',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export const databaseConfig = () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
});
|
||||
export const databaseConfig = () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
export const appConfig = () => ({
|
||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||
env: process.env.APP_ENV || 'development',
|
||||
});
|
||||
|
||||
export const databaseConfig = () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const jwtConfig = () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
|
||||
export const redisConfig = () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
});
|
||||
|
||||
export const kafkaConfig = () => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||
});
|
||||
|
||||
export const smsConfig = () => ({
|
||||
// 阿里云 SMS 配置
|
||||
aliyun: {
|
||||
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
||||
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
||||
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
|
||||
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
|
||||
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
||||
},
|
||||
// 是否启用真实发送(开发环境可关闭)
|
||||
enabled: process.env.SMS_ENABLED === 'true',
|
||||
});
|
||||
|
||||
export const walletConfig = () => ({
|
||||
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
|
||||
});
|
||||
export const appConfig = () => ({
|
||||
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||
env: process.env.APP_ENV || 'development',
|
||||
});
|
||||
|
||||
export const databaseConfig = () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const jwtConfig = () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
|
||||
export const redisConfig = () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
});
|
||||
|
||||
export const kafkaConfig = () => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||
});
|
||||
|
||||
export const smsConfig = () => ({
|
||||
// 阿里云 SMS 配置
|
||||
aliyun: {
|
||||
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
||||
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
||||
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
|
||||
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
|
||||
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
||||
},
|
||||
// 是否启用真实发送(开发环境可关闭)
|
||||
enabled: process.env.SMS_ENABLED === 'true',
|
||||
});
|
||||
|
||||
export const walletConfig = () => ({
|
||||
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const jwtConfig = () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
export const jwtConfig = () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const kafkaConfig = () => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||
});
|
||||
export const kafkaConfig = () => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export const redisConfig = () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
});
|
||||
export const redisConfig = () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from './user-account.aggregate';
|
||||
export * from './user-account.aggregate';
|
||||
|
|
|
|||
|
|
@ -1,356 +1,524 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import {
|
||||
UserId, AccountSequence, PhoneNumber, ReferralCode,
|
||||
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
|
||||
} from '@/domain/value-objects';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import {
|
||||
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
|
||||
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
|
||||
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
|
||||
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
|
||||
UserAccountFrozenEvent, UserAccountDeactivatedEvent,
|
||||
} from '@/domain/events';
|
||||
|
||||
export class UserAccount {
|
||||
private readonly _userId: UserId;
|
||||
private readonly _accountSequence: AccountSequence;
|
||||
private _devices: Map<string, DeviceInfo>;
|
||||
private _phoneNumber: PhoneNumber | null;
|
||||
private _nickname: string;
|
||||
private _avatarUrl: string | null;
|
||||
private readonly _inviterSequence: AccountSequence | null;
|
||||
private readonly _referralCode: ReferralCode;
|
||||
private _walletAddresses: Map<ChainType, WalletAddress>;
|
||||
private _kycInfo: KYCInfo | null;
|
||||
private _kycStatus: KYCStatus;
|
||||
private _status: AccountStatus;
|
||||
private readonly _registeredAt: Date;
|
||||
private _lastLoginAt: Date | null;
|
||||
private _updatedAt: Date;
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
// Getters
|
||||
get userId(): UserId { return this._userId; }
|
||||
get accountSequence(): AccountSequence { return this._accountSequence; }
|
||||
get phoneNumber(): PhoneNumber | null { return this._phoneNumber; }
|
||||
get nickname(): string { return this._nickname; }
|
||||
get avatarUrl(): string | null { return this._avatarUrl; }
|
||||
get inviterSequence(): AccountSequence | null { return this._inviterSequence; }
|
||||
get referralCode(): ReferralCode { return this._referralCode; }
|
||||
get kycInfo(): KYCInfo | null { return this._kycInfo; }
|
||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
||||
get status(): AccountStatus { return this._status; }
|
||||
get registeredAt(): Date { return this._registeredAt; }
|
||||
get lastLoginAt(): Date | null { return this._lastLoginAt; }
|
||||
get updatedAt(): Date { return this._updatedAt; }
|
||||
get isActive(): boolean { return this._status === AccountStatus.ACTIVE; }
|
||||
get isKYCVerified(): boolean { return this._kycStatus === KYCStatus.VERIFIED; }
|
||||
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||||
|
||||
private constructor(
|
||||
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>,
|
||||
phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null,
|
||||
inviterSequence: AccountSequence | null, referralCode: ReferralCode,
|
||||
walletAddresses: Map<ChainType, WalletAddress>, kycInfo: KYCInfo | null,
|
||||
kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date,
|
||||
lastLoginAt: Date | null, updatedAt: Date,
|
||||
) {
|
||||
this._userId = userId;
|
||||
this._accountSequence = accountSequence;
|
||||
this._devices = devices;
|
||||
this._phoneNumber = phoneNumber;
|
||||
this._nickname = nickname;
|
||||
this._avatarUrl = avatarUrl;
|
||||
this._inviterSequence = inviterSequence;
|
||||
this._referralCode = referralCode;
|
||||
this._walletAddresses = walletAddresses;
|
||||
this._kycInfo = kycInfo;
|
||||
this._kycStatus = kycStatus;
|
||||
this._status = status;
|
||||
this._registeredAt = registeredAt;
|
||||
this._lastLoginAt = lastLoginAt;
|
||||
this._updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
static createAutomatic(params: {
|
||||
accountSequence: AccountSequence;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||
inviterSequence: AccountSequence | null;
|
||||
nickname?: string;
|
||||
avatarSvg?: string;
|
||||
}): UserAccount {
|
||||
const devices = new Map<string, DeviceInfo>();
|
||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
||||
params.deviceInfo, // 传递完整的 JSON
|
||||
));
|
||||
|
||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||
const nickname = params.nickname || `用户${params.accountSequence.dailySequence}`;
|
||||
const avatarUrl = params.avatarSvg || null;
|
||||
|
||||
const account = new UserAccount(
|
||||
UserId.create(0), params.accountSequence, devices, null,
|
||||
nickname, avatarUrl, params.inviterSequence,
|
||||
ReferralCode.generate(),
|
||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
||||
new Date(), null, new Date(),
|
||||
);
|
||||
|
||||
account.addDomainEvent(new UserAccountAutoCreatedEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: params.accountSequence.value,
|
||||
referralCode: account._referralCode.value, // 用户的推荐码
|
||||
initialDeviceId: params.initialDeviceId,
|
||||
inviterSequence: params.inviterSequence?.value || null,
|
||||
registeredAt: account._registeredAt,
|
||||
}));
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
accountSequence: AccountSequence;
|
||||
phoneNumber: PhoneNumber;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
const devices = new Map<string, DeviceInfo>();
|
||||
devices.set(params.initialDeviceId, new DeviceInfo(
|
||||
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
||||
params.deviceInfo,
|
||||
));
|
||||
|
||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||
const account = new UserAccount(
|
||||
UserId.create(0), params.accountSequence, devices, params.phoneNumber,
|
||||
`用户${params.accountSequence.dailySequence}`, null, params.inviterSequence,
|
||||
ReferralCode.generate(),
|
||||
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
||||
new Date(), null, new Date(),
|
||||
);
|
||||
|
||||
account.addDomainEvent(new UserAccountCreatedEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: params.accountSequence.value,
|
||||
referralCode: account._referralCode.value, // 用户的推荐码
|
||||
phoneNumber: params.phoneNumber.value,
|
||||
initialDeviceId: params.initialDeviceId,
|
||||
inviterSequence: params.inviterSequence?.value || null,
|
||||
registeredAt: account._registeredAt,
|
||||
}));
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
static reconstruct(params: {
|
||||
userId: string; accountSequence: string; devices: DeviceInfo[];
|
||||
phoneNumber: string | null; nickname: string; avatarUrl: string | null;
|
||||
inviterSequence: string | null; referralCode: string;
|
||||
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null;
|
||||
kycStatus: KYCStatus; status: AccountStatus;
|
||||
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
|
||||
}): UserAccount {
|
||||
const deviceMap = new Map<string, DeviceInfo>();
|
||||
params.devices.forEach(d => deviceMap.set(d.deviceId, d));
|
||||
|
||||
const walletMap = new Map<ChainType, WalletAddress>();
|
||||
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w));
|
||||
|
||||
return new UserAccount(
|
||||
UserId.create(params.userId),
|
||||
AccountSequence.create(params.accountSequence),
|
||||
deviceMap,
|
||||
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
||||
params.nickname,
|
||||
params.avatarUrl,
|
||||
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null,
|
||||
ReferralCode.create(params.referralCode),
|
||||
walletMap,
|
||||
params.kycInfo,
|
||||
params.kycStatus,
|
||||
params.status,
|
||||
params.registeredAt,
|
||||
params.lastLoginAt,
|
||||
params.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
addDevice(deviceId: string, deviceName?: string, deviceInfo?: Record<string, unknown>): void {
|
||||
this.ensureActive();
|
||||
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
||||
throw new DomainError('最多允许5个设备同时登录');
|
||||
}
|
||||
if (this._devices.has(deviceId)) {
|
||||
const device = this._devices.get(deviceId)!;
|
||||
device.updateActivity();
|
||||
if (deviceInfo) {
|
||||
device.updateDeviceInfo(deviceInfo);
|
||||
}
|
||||
} else {
|
||||
this._devices.set(deviceId, new DeviceInfo(
|
||||
deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo,
|
||||
));
|
||||
this.addDomainEvent(new DeviceAddedEvent({
|
||||
userId: this.userId.toString(),
|
||||
accountSequence: this.accountSequence.value,
|
||||
deviceId,
|
||||
deviceName: deviceName || '未命名设备',
|
||||
}));
|
||||
}
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): void {
|
||||
this.ensureActive();
|
||||
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
|
||||
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
||||
this._devices.delete(deviceId);
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }));
|
||||
}
|
||||
|
||||
isDeviceAuthorized(deviceId: string): boolean {
|
||||
return this._devices.has(deviceId);
|
||||
}
|
||||
|
||||
getAllDevices(): DeviceInfo[] {
|
||||
return Array.from(this._devices.values());
|
||||
}
|
||||
|
||||
updateProfile(params: { nickname?: string; avatarUrl?: string }): void {
|
||||
this.ensureActive();
|
||||
if (params.nickname) this._nickname = params.nickname;
|
||||
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
bindPhoneNumber(phoneNumber: PhoneNumber): void {
|
||||
this.ensureActive();
|
||||
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
||||
this._phoneNumber = phoneNumber;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.toString(), phoneNumber: phoneNumber.value }));
|
||||
}
|
||||
|
||||
bindWalletAddress(chainType: ChainType, address: string): void {
|
||||
this.ensureActive();
|
||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
||||
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
|
||||
this._walletAddresses.set(chainType, walletAddress);
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.toString(), chainType, address }));
|
||||
}
|
||||
|
||||
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
||||
this.ensureActive();
|
||||
for (const [chainType, wallet] of wallets) {
|
||||
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
||||
this._walletAddresses.set(chainType, wallet);
|
||||
}
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({
|
||||
userId: this.userId.toString(),
|
||||
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })),
|
||||
}));
|
||||
}
|
||||
|
||||
submitKYC(kycInfo: KYCInfo): void {
|
||||
this.ensureActive();
|
||||
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交');
|
||||
this._kycInfo = kycInfo;
|
||||
this._kycStatus = KYCStatus.PENDING;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new KYCSubmittedEvent({
|
||||
userId: this.userId.toString(), realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber,
|
||||
}));
|
||||
}
|
||||
|
||||
approveKYC(): void {
|
||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC');
|
||||
this._kycStatus = KYCStatus.VERIFIED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.toString(), verifiedAt: new Date() }));
|
||||
}
|
||||
|
||||
rejectKYC(reason: string): void {
|
||||
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC');
|
||||
this._kycStatus = KYCStatus.REJECTED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.toString(), reason }));
|
||||
}
|
||||
|
||||
recordLogin(): void {
|
||||
this.ensureActive();
|
||||
this._lastLoginAt = new Date();
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
freeze(reason: string): void {
|
||||
if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结');
|
||||
this._status = AccountStatus.FROZEN;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }));
|
||||
}
|
||||
|
||||
unfreeze(): void {
|
||||
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结');
|
||||
this._status = AccountStatus.ACTIVE;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销');
|
||||
this._status = AccountStatus.DEACTIVATED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.toString(), deactivatedAt: new Date() }));
|
||||
}
|
||||
|
||||
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
||||
return this._walletAddresses.get(chainType) || null;
|
||||
}
|
||||
|
||||
getAllWalletAddresses(): WalletAddress[] {
|
||||
return Array.from(this._walletAddresses.values());
|
||||
}
|
||||
|
||||
private ensureActive(): void {
|
||||
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
private addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建钱包生成事件(用于重试)
|
||||
*
|
||||
* 重新发布 UserAccountCreatedEvent 以触发 MPC 钱包生成流程
|
||||
* 这个方法是幂等的,可以安全地多次调用
|
||||
*/
|
||||
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
||||
// 获取第一个设备的信息
|
||||
const firstDevice = this._devices.values().next().value as DeviceInfo | undefined;
|
||||
|
||||
return new UserAccountCreatedEvent({
|
||||
userId: this._userId.toString(),
|
||||
accountSequence: this._accountSequence.value,
|
||||
referralCode: this._referralCode.value,
|
||||
phoneNumber: this._phoneNumber?.value || null,
|
||||
initialDeviceId: firstDevice?.deviceId || 'retry-unknown',
|
||||
deviceName: firstDevice?.deviceName || 'retry-device',
|
||||
deviceInfo: firstDevice?.deviceInfo || null,
|
||||
inviterReferralCode: null, // 重试时不需要
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import {
|
||||
UserId,
|
||||
AccountSequence,
|
||||
PhoneNumber,
|
||||
ReferralCode,
|
||||
DeviceInfo,
|
||||
ChainType,
|
||||
KYCInfo,
|
||||
KYCStatus,
|
||||
AccountStatus,
|
||||
} from '@/domain/value-objects';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import {
|
||||
DomainEvent,
|
||||
UserAccountAutoCreatedEvent,
|
||||
UserAccountCreatedEvent,
|
||||
DeviceAddedEvent,
|
||||
DeviceRemovedEvent,
|
||||
PhoneNumberBoundEvent,
|
||||
WalletAddressBoundEvent,
|
||||
MultipleWalletAddressesBoundEvent,
|
||||
KYCSubmittedEvent,
|
||||
KYCVerifiedEvent,
|
||||
KYCRejectedEvent,
|
||||
UserAccountFrozenEvent,
|
||||
UserAccountDeactivatedEvent,
|
||||
} from '@/domain/events';
|
||||
|
||||
export class UserAccount {
|
||||
private readonly _userId: UserId;
|
||||
private readonly _accountSequence: AccountSequence;
|
||||
private _devices: Map<string, DeviceInfo>;
|
||||
private _phoneNumber: PhoneNumber | null;
|
||||
private _nickname: string;
|
||||
private _avatarUrl: string | null;
|
||||
private readonly _inviterSequence: AccountSequence | null;
|
||||
private readonly _referralCode: ReferralCode;
|
||||
private _walletAddresses: Map<ChainType, WalletAddress>;
|
||||
private _kycInfo: KYCInfo | null;
|
||||
private _kycStatus: KYCStatus;
|
||||
private _status: AccountStatus;
|
||||
private readonly _registeredAt: Date;
|
||||
private _lastLoginAt: Date | null;
|
||||
private _updatedAt: Date;
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
// Getters
|
||||
get userId(): UserId {
|
||||
return this._userId;
|
||||
}
|
||||
get accountSequence(): AccountSequence {
|
||||
return this._accountSequence;
|
||||
}
|
||||
get phoneNumber(): PhoneNumber | null {
|
||||
return this._phoneNumber;
|
||||
}
|
||||
get nickname(): string {
|
||||
return this._nickname;
|
||||
}
|
||||
get avatarUrl(): string | null {
|
||||
return this._avatarUrl;
|
||||
}
|
||||
get inviterSequence(): AccountSequence | null {
|
||||
return this._inviterSequence;
|
||||
}
|
||||
get referralCode(): ReferralCode {
|
||||
return this._referralCode;
|
||||
}
|
||||
get kycInfo(): KYCInfo | null {
|
||||
return this._kycInfo;
|
||||
}
|
||||
get kycStatus(): KYCStatus {
|
||||
return this._kycStatus;
|
||||
}
|
||||
get status(): AccountStatus {
|
||||
return this._status;
|
||||
}
|
||||
get registeredAt(): Date {
|
||||
return this._registeredAt;
|
||||
}
|
||||
get lastLoginAt(): Date | null {
|
||||
return this._lastLoginAt;
|
||||
}
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
get isActive(): boolean {
|
||||
return this._status === AccountStatus.ACTIVE;
|
||||
}
|
||||
get isKYCVerified(): boolean {
|
||||
return this._kycStatus === KYCStatus.VERIFIED;
|
||||
}
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
private constructor(
|
||||
userId: UserId,
|
||||
accountSequence: AccountSequence,
|
||||
devices: Map<string, DeviceInfo>,
|
||||
phoneNumber: PhoneNumber | null,
|
||||
nickname: string,
|
||||
avatarUrl: string | null,
|
||||
inviterSequence: AccountSequence | null,
|
||||
referralCode: ReferralCode,
|
||||
walletAddresses: Map<ChainType, WalletAddress>,
|
||||
kycInfo: KYCInfo | null,
|
||||
kycStatus: KYCStatus,
|
||||
status: AccountStatus,
|
||||
registeredAt: Date,
|
||||
lastLoginAt: Date | null,
|
||||
updatedAt: Date,
|
||||
) {
|
||||
this._userId = userId;
|
||||
this._accountSequence = accountSequence;
|
||||
this._devices = devices;
|
||||
this._phoneNumber = phoneNumber;
|
||||
this._nickname = nickname;
|
||||
this._avatarUrl = avatarUrl;
|
||||
this._inviterSequence = inviterSequence;
|
||||
this._referralCode = referralCode;
|
||||
this._walletAddresses = walletAddresses;
|
||||
this._kycInfo = kycInfo;
|
||||
this._kycStatus = kycStatus;
|
||||
this._status = status;
|
||||
this._registeredAt = registeredAt;
|
||||
this._lastLoginAt = lastLoginAt;
|
||||
this._updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
static createAutomatic(params: {
|
||||
accountSequence: AccountSequence;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||
inviterSequence: AccountSequence | null;
|
||||
nickname?: string;
|
||||
avatarSvg?: string;
|
||||
}): UserAccount {
|
||||
const devices = new Map<string, DeviceInfo>();
|
||||
devices.set(
|
||||
params.initialDeviceId,
|
||||
new DeviceInfo(
|
||||
params.initialDeviceId,
|
||||
params.deviceName || '未命名设备',
|
||||
new Date(),
|
||||
new Date(),
|
||||
params.deviceInfo, // 传递完整的 JSON
|
||||
),
|
||||
);
|
||||
|
||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||
const nickname =
|
||||
params.nickname || `用户${params.accountSequence.dailySequence}`;
|
||||
const avatarUrl = params.avatarSvg || null;
|
||||
|
||||
const account = new UserAccount(
|
||||
UserId.create(0),
|
||||
params.accountSequence,
|
||||
devices,
|
||||
null,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
params.inviterSequence,
|
||||
ReferralCode.generate(),
|
||||
new Map(),
|
||||
null,
|
||||
KYCStatus.NOT_VERIFIED,
|
||||
AccountStatus.ACTIVE,
|
||||
new Date(),
|
||||
null,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
account.addDomainEvent(
|
||||
new UserAccountAutoCreatedEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: params.accountSequence.value,
|
||||
referralCode: account._referralCode.value, // 用户的推荐码
|
||||
initialDeviceId: params.initialDeviceId,
|
||||
inviterSequence: params.inviterSequence?.value || null,
|
||||
registeredAt: account._registeredAt,
|
||||
}),
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
accountSequence: AccountSequence;
|
||||
phoneNumber: PhoneNumber;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
const devices = new Map<string, DeviceInfo>();
|
||||
devices.set(
|
||||
params.initialDeviceId,
|
||||
new DeviceInfo(
|
||||
params.initialDeviceId,
|
||||
params.deviceName || '未命名设备',
|
||||
new Date(),
|
||||
new Date(),
|
||||
params.deviceInfo,
|
||||
),
|
||||
);
|
||||
|
||||
// UserID将由数据库自动生成(autoincrement),这里使用临时值0
|
||||
const account = new UserAccount(
|
||||
UserId.create(0),
|
||||
params.accountSequence,
|
||||
devices,
|
||||
params.phoneNumber,
|
||||
`用户${params.accountSequence.dailySequence}`,
|
||||
null,
|
||||
params.inviterSequence,
|
||||
ReferralCode.generate(),
|
||||
new Map(),
|
||||
null,
|
||||
KYCStatus.NOT_VERIFIED,
|
||||
AccountStatus.ACTIVE,
|
||||
new Date(),
|
||||
null,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
account.addDomainEvent(
|
||||
new UserAccountCreatedEvent({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: params.accountSequence.value,
|
||||
referralCode: account._referralCode.value, // 用户的推荐码
|
||||
phoneNumber: params.phoneNumber.value,
|
||||
initialDeviceId: params.initialDeviceId,
|
||||
inviterSequence: params.inviterSequence?.value || null,
|
||||
registeredAt: account._registeredAt,
|
||||
}),
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
static reconstruct(params: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
devices: DeviceInfo[];
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
inviterSequence: string | null;
|
||||
referralCode: string;
|
||||
walletAddresses: WalletAddress[];
|
||||
kycInfo: KYCInfo | null;
|
||||
kycStatus: KYCStatus;
|
||||
status: AccountStatus;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}): UserAccount {
|
||||
const deviceMap = new Map<string, DeviceInfo>();
|
||||
params.devices.forEach((d) => deviceMap.set(d.deviceId, d));
|
||||
|
||||
const walletMap = new Map<ChainType, WalletAddress>();
|
||||
params.walletAddresses.forEach((w) => walletMap.set(w.chainType, w));
|
||||
|
||||
return new UserAccount(
|
||||
UserId.create(params.userId),
|
||||
AccountSequence.create(params.accountSequence),
|
||||
deviceMap,
|
||||
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
|
||||
params.nickname,
|
||||
params.avatarUrl,
|
||||
params.inviterSequence
|
||||
? AccountSequence.create(params.inviterSequence)
|
||||
: null,
|
||||
ReferralCode.create(params.referralCode),
|
||||
walletMap,
|
||||
params.kycInfo,
|
||||
params.kycStatus,
|
||||
params.status,
|
||||
params.registeredAt,
|
||||
params.lastLoginAt,
|
||||
params.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
addDevice(
|
||||
deviceId: string,
|
||||
deviceName?: string,
|
||||
deviceInfo?: Record<string, unknown>,
|
||||
): void {
|
||||
this.ensureActive();
|
||||
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
||||
throw new DomainError('最多允许5个设备同时登录');
|
||||
}
|
||||
if (this._devices.has(deviceId)) {
|
||||
const device = this._devices.get(deviceId)!;
|
||||
device.updateActivity();
|
||||
if (deviceInfo) {
|
||||
device.updateDeviceInfo(deviceInfo);
|
||||
}
|
||||
} else {
|
||||
this._devices.set(
|
||||
deviceId,
|
||||
new DeviceInfo(
|
||||
deviceId,
|
||||
deviceName || '未命名设备',
|
||||
new Date(),
|
||||
new Date(),
|
||||
deviceInfo,
|
||||
),
|
||||
);
|
||||
this.addDomainEvent(
|
||||
new DeviceAddedEvent({
|
||||
userId: this.userId.toString(),
|
||||
accountSequence: this.accountSequence.value,
|
||||
deviceId,
|
||||
deviceName: deviceName || '未命名设备',
|
||||
}),
|
||||
);
|
||||
}
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): void {
|
||||
this.ensureActive();
|
||||
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
|
||||
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
||||
this._devices.delete(deviceId);
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new DeviceRemovedEvent({ userId: this.userId.toString(), deviceId }),
|
||||
);
|
||||
}
|
||||
|
||||
isDeviceAuthorized(deviceId: string): boolean {
|
||||
return this._devices.has(deviceId);
|
||||
}
|
||||
|
||||
getAllDevices(): DeviceInfo[] {
|
||||
return Array.from(this._devices.values());
|
||||
}
|
||||
|
||||
updateProfile(params: { nickname?: string; avatarUrl?: string }): void {
|
||||
this.ensureActive();
|
||||
if (params.nickname) this._nickname = params.nickname;
|
||||
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
bindPhoneNumber(phoneNumber: PhoneNumber): void {
|
||||
this.ensureActive();
|
||||
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
||||
this._phoneNumber = phoneNumber;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new PhoneNumberBoundEvent({
|
||||
userId: this.userId.toString(),
|
||||
phoneNumber: phoneNumber.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bindWalletAddress(chainType: ChainType, address: string): void {
|
||||
this.ensureActive();
|
||||
if (this._walletAddresses.has(chainType))
|
||||
throw new DomainError(`已绑定${chainType}地址`);
|
||||
const walletAddress = WalletAddress.create({
|
||||
userId: this.userId,
|
||||
chainType,
|
||||
address,
|
||||
});
|
||||
this._walletAddresses.set(chainType, walletAddress);
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new WalletAddressBoundEvent({
|
||||
userId: this.userId.toString(),
|
||||
chainType,
|
||||
address,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
||||
this.ensureActive();
|
||||
for (const [chainType, wallet] of wallets) {
|
||||
if (this._walletAddresses.has(chainType))
|
||||
throw new DomainError(`已绑定${chainType}地址`);
|
||||
this._walletAddresses.set(chainType, wallet);
|
||||
}
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new MultipleWalletAddressesBoundEvent({
|
||||
userId: this.userId.toString(),
|
||||
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({
|
||||
chainType,
|
||||
address: wallet.address,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
submitKYC(kycInfo: KYCInfo): void {
|
||||
this.ensureActive();
|
||||
if (this._kycStatus === KYCStatus.VERIFIED)
|
||||
throw new DomainError('已通过KYC认证,不可重复提交');
|
||||
this._kycInfo = kycInfo;
|
||||
this._kycStatus = KYCStatus.PENDING;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new KYCSubmittedEvent({
|
||||
userId: this.userId.toString(),
|
||||
realName: kycInfo.realName,
|
||||
idCardNumber: kycInfo.idCardNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
approveKYC(): void {
|
||||
if (this._kycStatus !== KYCStatus.PENDING)
|
||||
throw new DomainError('只有待审核状态才能通过KYC');
|
||||
this._kycStatus = KYCStatus.VERIFIED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new KYCVerifiedEvent({
|
||||
userId: this.userId.toString(),
|
||||
verifiedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rejectKYC(reason: string): void {
|
||||
if (this._kycStatus !== KYCStatus.PENDING)
|
||||
throw new DomainError('只有待审核状态才能拒绝KYC');
|
||||
this._kycStatus = KYCStatus.REJECTED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new KYCRejectedEvent({ userId: this.userId.toString(), reason }),
|
||||
);
|
||||
}
|
||||
|
||||
recordLogin(): void {
|
||||
this.ensureActive();
|
||||
this._lastLoginAt = new Date();
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
freeze(reason: string): void {
|
||||
if (this._status === AccountStatus.FROZEN)
|
||||
throw new DomainError('账户已冻结');
|
||||
this._status = AccountStatus.FROZEN;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new UserAccountFrozenEvent({ userId: this.userId.toString(), reason }),
|
||||
);
|
||||
}
|
||||
|
||||
unfreeze(): void {
|
||||
if (this._status !== AccountStatus.FROZEN)
|
||||
throw new DomainError('账户未冻结');
|
||||
this._status = AccountStatus.ACTIVE;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
if (this._status === AccountStatus.DEACTIVATED)
|
||||
throw new DomainError('账户已注销');
|
||||
this._status = AccountStatus.DEACTIVATED;
|
||||
this._updatedAt = new Date();
|
||||
this.addDomainEvent(
|
||||
new UserAccountDeactivatedEvent({
|
||||
userId: this.userId.toString(),
|
||||
deactivatedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
||||
return this._walletAddresses.get(chainType) || null;
|
||||
}
|
||||
|
||||
getAllWalletAddresses(): WalletAddress[] {
|
||||
return Array.from(this._walletAddresses.values());
|
||||
}
|
||||
|
||||
private ensureActive(): void {
|
||||
if (this._status !== AccountStatus.ACTIVE)
|
||||
throw new DomainError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
private addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建钱包生成事件(用于重试)
|
||||
*
|
||||
* 重新发布 UserAccountCreatedEvent 以触发 MPC 钱包生成流程
|
||||
* 这个方法是幂等的,可以安全地多次调用
|
||||
*/
|
||||
createWalletGenerationEvent(): UserAccountCreatedEvent {
|
||||
// 获取第一个设备的信息
|
||||
const firstDevice = this._devices.values().next().value as
|
||||
| DeviceInfo
|
||||
| undefined;
|
||||
|
||||
return new UserAccountCreatedEvent({
|
||||
userId: this._userId.toString(),
|
||||
accountSequence: this._accountSequence.value,
|
||||
referralCode: this._referralCode.value,
|
||||
phoneNumber: this._phoneNumber?.value || null,
|
||||
initialDeviceId: firstDevice?.deviceId || 'retry-unknown',
|
||||
deviceName: firstDevice?.deviceName || 'retry-device',
|
||||
deviceInfo: firstDevice?.deviceInfo || null,
|
||||
inviterReferralCode: null, // 重试时不需要
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { UserAccount } from './user-account.aggregate';
|
||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class UserAccountFactory {
|
||||
createAutomatic(params: {
|
||||
accountSequence: AccountSequence;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
return UserAccount.createAutomatic(params);
|
||||
}
|
||||
|
||||
create(params: {
|
||||
accountSequence: AccountSequence;
|
||||
phoneNumber: PhoneNumber;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
return UserAccount.create(params);
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserAccount } from './user-account.aggregate';
|
||||
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class UserAccountFactory {
|
||||
createAutomatic(params: {
|
||||
accountSequence: AccountSequence;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
return UserAccount.createAutomatic(params);
|
||||
}
|
||||
|
||||
create(params: {
|
||||
accountSequence: AccountSequence;
|
||||
phoneNumber: PhoneNumber;
|
||||
initialDeviceId: string;
|
||||
deviceName?: string;
|
||||
inviterSequence: AccountSequence | null;
|
||||
}): UserAccount {
|
||||
return UserAccount.create(params);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AccountSequenceGeneratorService, UserValidatorService } from './services';
|
||||
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
||||
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
providers: [
|
||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
UserAccountFactory,
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
UserAccountFactory,
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
} from './services';
|
||||
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
||||
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
providers: [
|
||||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
UserAccountFactory,
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
UserAccountFactory,
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from './wallet-address.entity';
|
||||
export * from './wallet-address.entity';
|
||||
|
|
|
|||
|
|
@ -1,286 +1,327 @@
|
|||
import { HDKey } from '@scure/bip32';
|
||||
import { createHash } from 'crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
import { Wallet } from 'ethers';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import {
|
||||
AddressId,
|
||||
UserId,
|
||||
ChainType,
|
||||
CHAIN_CONFIG,
|
||||
AddressStatus,
|
||||
Mnemonic,
|
||||
MnemonicEncryption,
|
||||
} from '@/domain/value-objects';
|
||||
|
||||
/**
|
||||
* MPC 签名信息
|
||||
* 64 bytes hex (R 32 bytes + S 32 bytes)
|
||||
*/
|
||||
export type MpcSignature = string;
|
||||
|
||||
export class WalletAddress {
|
||||
private readonly _addressId: AddressId;
|
||||
private readonly _userId: UserId;
|
||||
private readonly _chainType: ChainType;
|
||||
private readonly _address: string;
|
||||
private readonly _publicKey: string; // MPC 公钥
|
||||
private readonly _addressDigest: string; // 地址摘要
|
||||
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
||||
private _status: AddressStatus;
|
||||
private readonly _boundAt: Date;
|
||||
|
||||
get addressId(): AddressId { return this._addressId; }
|
||||
get userId(): UserId { return this._userId; }
|
||||
get chainType(): ChainType { return this._chainType; }
|
||||
get address(): string { return this._address; }
|
||||
get publicKey(): string { return this._publicKey; }
|
||||
get addressDigest(): string { return this._addressDigest; }
|
||||
get mpcSignature(): MpcSignature { return this._mpcSignature; }
|
||||
get status(): AddressStatus { return this._status; }
|
||||
get boundAt(): Date { return this._boundAt; }
|
||||
|
||||
private constructor(
|
||||
addressId: AddressId,
|
||||
userId: UserId,
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
publicKey: string,
|
||||
addressDigest: string,
|
||||
mpcSignature: MpcSignature,
|
||||
status: AddressStatus,
|
||||
boundAt: Date,
|
||||
) {
|
||||
this._addressId = addressId;
|
||||
this._userId = userId;
|
||||
this._chainType = chainType;
|
||||
this._address = address;
|
||||
this._publicKey = publicKey;
|
||||
this._addressDigest = addressDigest;
|
||||
this._mpcSignature = mpcSignature;
|
||||
this._status = status;
|
||||
this._boundAt = boundAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MPC 钱包地址
|
||||
*
|
||||
* @param params 包含 MPC 签名验证信息的参数
|
||||
*/
|
||||
static createMpc(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
signature: MpcSignature;
|
||||
}): WalletAddress {
|
||||
if (!this.validateEvmAddress(params.address)) {
|
||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||
}
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey,
|
||||
params.addressDigest,
|
||||
params.signature,
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库重建实体
|
||||
*/
|
||||
static reconstruct(params: {
|
||||
addressId: string;
|
||||
userId: string;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
mpcSignature: string; // 64 bytes hex
|
||||
status: AddressStatus;
|
||||
boundAt: Date;
|
||||
}): WalletAddress {
|
||||
return new WalletAddress(
|
||||
AddressId.create(params.addressId),
|
||||
UserId.create(params.userId),
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey,
|
||||
params.addressDigest,
|
||||
params.mpcSignature,
|
||||
params.status,
|
||||
params.boundAt,
|
||||
);
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this._status = AddressStatus.DISABLED;
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this._status = AddressStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名是否有效
|
||||
* 用于检测地址是否被篡改
|
||||
*/
|
||||
async verifySignature(): Promise<boolean> {
|
||||
try {
|
||||
const { ethers } = await import('ethers');
|
||||
|
||||
// 计算预期的摘要
|
||||
const expectedDigest = this.computeDigest();
|
||||
if (expectedDigest !== this._addressDigest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars
|
||||
if (this._mpcSignature.length !== 128) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = '0x' + this._mpcSignature.slice(0, 64);
|
||||
const s = '0x' + this._mpcSignature.slice(64, 128);
|
||||
const digestBytes = Buffer.from(this._addressDigest, 'hex');
|
||||
|
||||
// 尝试两种 recovery id
|
||||
for (const v of [27, 28]) {
|
||||
try {
|
||||
const sig = ethers.Signature.from({ r, s, v });
|
||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
||||
|
||||
if (compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 尝试下一个 v 值
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算地址摘要
|
||||
*/
|
||||
private computeDigest(): string {
|
||||
const message = `${this._chainType}:${this._address.toLowerCase()}`;
|
||||
return createHash('sha256').update(message).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 EVM 地址格式
|
||||
*/
|
||||
private static validateEvmAddress(address: string): boolean {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
}
|
||||
|
||||
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
||||
|
||||
/**
|
||||
* 创建钱包地址(简化版,用于从 blockchain-service 接收地址)
|
||||
*/
|
||||
static create(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey?: string; // 公钥
|
||||
}): WalletAddress {
|
||||
if (!this.validateAddress(params.chainType, params.address)) {
|
||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||
}
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey || '',
|
||||
'',
|
||||
'', // empty signature
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated MPC 模式下不再使用助记词
|
||||
*/
|
||||
static createFromMnemonic(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
mnemonic: Mnemonic;
|
||||
encryptionKey: string;
|
||||
}): WalletAddress {
|
||||
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
address,
|
||||
'',
|
||||
'',
|
||||
'', // empty signature
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
||||
const seed = mnemonic.toSeed();
|
||||
const config = CHAIN_CONFIG[chainType];
|
||||
|
||||
switch (chainType) {
|
||||
case ChainType.KAVA:
|
||||
case ChainType.DST:
|
||||
return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix);
|
||||
case ChainType.BSC:
|
||||
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
||||
default:
|
||||
throw new DomainError(`不支持的链类型: ${chainType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string {
|
||||
const hdkey = HDKey.fromMasterSeed(seed);
|
||||
const childKey = hdkey.derive(path);
|
||||
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
||||
|
||||
const hash = createHash('sha256').update(childKey.publicKey).digest();
|
||||
const addressHash = createHash('ripemd160').update(hash).digest();
|
||||
const words = bech32.toWords(addressHash);
|
||||
return bech32.encode(prefix, words);
|
||||
}
|
||||
|
||||
private static deriveEVMAddress(seed: Buffer, path: string): string {
|
||||
const hdkey = HDKey.fromMasterSeed(seed);
|
||||
const childKey = hdkey.derive(path);
|
||||
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
|
||||
|
||||
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
|
||||
return wallet.address;
|
||||
}
|
||||
|
||||
private static validateAddress(chainType: ChainType, address: string): boolean {
|
||||
switch (chainType) {
|
||||
case ChainType.KAVA:
|
||||
case ChainType.BSC:
|
||||
// KAVA 和 BSC 都使用 EVM 地址格式
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
case ChainType.DST:
|
||||
// DST 使用 Cosmos bech32 格式
|
||||
return /^dst1[a-z0-9]{38}$/.test(address);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { createHash } from 'crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
import { Wallet } from 'ethers';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import {
|
||||
AddressId,
|
||||
UserId,
|
||||
ChainType,
|
||||
CHAIN_CONFIG,
|
||||
AddressStatus,
|
||||
Mnemonic,
|
||||
MnemonicEncryption,
|
||||
} from '@/domain/value-objects';
|
||||
|
||||
/**
|
||||
* MPC 签名信息
|
||||
* 64 bytes hex (R 32 bytes + S 32 bytes)
|
||||
*/
|
||||
export type MpcSignature = string;
|
||||
|
||||
export class WalletAddress {
|
||||
private readonly _addressId: AddressId;
|
||||
private readonly _userId: UserId;
|
||||
private readonly _chainType: ChainType;
|
||||
private readonly _address: string;
|
||||
private readonly _publicKey: string; // MPC 公钥
|
||||
private readonly _addressDigest: string; // 地址摘要
|
||||
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
||||
private _status: AddressStatus;
|
||||
private readonly _boundAt: Date;
|
||||
|
||||
get addressId(): AddressId {
|
||||
return this._addressId;
|
||||
}
|
||||
get userId(): UserId {
|
||||
return this._userId;
|
||||
}
|
||||
get chainType(): ChainType {
|
||||
return this._chainType;
|
||||
}
|
||||
get address(): string {
|
||||
return this._address;
|
||||
}
|
||||
get publicKey(): string {
|
||||
return this._publicKey;
|
||||
}
|
||||
get addressDigest(): string {
|
||||
return this._addressDigest;
|
||||
}
|
||||
get mpcSignature(): MpcSignature {
|
||||
return this._mpcSignature;
|
||||
}
|
||||
get status(): AddressStatus {
|
||||
return this._status;
|
||||
}
|
||||
get boundAt(): Date {
|
||||
return this._boundAt;
|
||||
}
|
||||
|
||||
private constructor(
|
||||
addressId: AddressId,
|
||||
userId: UserId,
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
publicKey: string,
|
||||
addressDigest: string,
|
||||
mpcSignature: MpcSignature,
|
||||
status: AddressStatus,
|
||||
boundAt: Date,
|
||||
) {
|
||||
this._addressId = addressId;
|
||||
this._userId = userId;
|
||||
this._chainType = chainType;
|
||||
this._address = address;
|
||||
this._publicKey = publicKey;
|
||||
this._addressDigest = addressDigest;
|
||||
this._mpcSignature = mpcSignature;
|
||||
this._status = status;
|
||||
this._boundAt = boundAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MPC 钱包地址
|
||||
*
|
||||
* @param params 包含 MPC 签名验证信息的参数
|
||||
*/
|
||||
static createMpc(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
signature: MpcSignature;
|
||||
}): WalletAddress {
|
||||
if (!this.validateEvmAddress(params.address)) {
|
||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||
}
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey,
|
||||
params.addressDigest,
|
||||
params.signature,
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库重建实体
|
||||
*/
|
||||
static reconstruct(params: {
|
||||
addressId: string;
|
||||
userId: string;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
mpcSignature: string; // 64 bytes hex
|
||||
status: AddressStatus;
|
||||
boundAt: Date;
|
||||
}): WalletAddress {
|
||||
return new WalletAddress(
|
||||
AddressId.create(params.addressId),
|
||||
UserId.create(params.userId),
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey,
|
||||
params.addressDigest,
|
||||
params.mpcSignature,
|
||||
params.status,
|
||||
params.boundAt,
|
||||
);
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this._status = AddressStatus.DISABLED;
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this._status = AddressStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名是否有效
|
||||
* 用于检测地址是否被篡改
|
||||
*/
|
||||
async verifySignature(): Promise<boolean> {
|
||||
try {
|
||||
const { ethers } = await import('ethers');
|
||||
|
||||
// 计算预期的摘要
|
||||
const expectedDigest = this.computeDigest();
|
||||
if (expectedDigest !== this._addressDigest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex = 128 chars
|
||||
if (this._mpcSignature.length !== 128) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = '0x' + this._mpcSignature.slice(0, 64);
|
||||
const s = '0x' + this._mpcSignature.slice(64, 128);
|
||||
const digestBytes = Buffer.from(this._addressDigest, 'hex');
|
||||
|
||||
// 尝试两种 recovery id
|
||||
for (const v of [27, 28]) {
|
||||
try {
|
||||
const sig = ethers.Signature.from({ r, s, v });
|
||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||
digestBytes,
|
||||
sig,
|
||||
);
|
||||
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||
recoveredPubKey,
|
||||
true,
|
||||
);
|
||||
|
||||
if (
|
||||
compressedRecovered.slice(2).toLowerCase() ===
|
||||
this._publicKey.toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 尝试下一个 v 值
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算地址摘要
|
||||
*/
|
||||
private computeDigest(): string {
|
||||
const message = `${this._chainType}:${this._address.toLowerCase()}`;
|
||||
return createHash('sha256').update(message).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 EVM 地址格式
|
||||
*/
|
||||
private static validateEvmAddress(address: string): boolean {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
}
|
||||
|
||||
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
||||
|
||||
/**
|
||||
* 创建钱包地址(简化版,用于从 blockchain-service 接收地址)
|
||||
*/
|
||||
static create(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey?: string; // 公钥
|
||||
}): WalletAddress {
|
||||
if (!this.validateAddress(params.chainType, params.address)) {
|
||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||
}
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
params.address,
|
||||
params.publicKey || '',
|
||||
'',
|
||||
'', // empty signature
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated MPC 模式下不再使用助记词
|
||||
*/
|
||||
static createFromMnemonic(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
mnemonic: Mnemonic;
|
||||
encryptionKey: string;
|
||||
}): WalletAddress {
|
||||
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
||||
return new WalletAddress(
|
||||
AddressId.generate(),
|
||||
params.userId,
|
||||
params.chainType,
|
||||
address,
|
||||
'',
|
||||
'',
|
||||
'', // empty signature
|
||||
AddressStatus.ACTIVE,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
private static deriveAddress(
|
||||
chainType: ChainType,
|
||||
mnemonic: Mnemonic,
|
||||
): string {
|
||||
const seed = mnemonic.toSeed();
|
||||
const config = CHAIN_CONFIG[chainType];
|
||||
|
||||
switch (chainType) {
|
||||
case ChainType.KAVA:
|
||||
case ChainType.DST:
|
||||
return this.deriveCosmosAddress(
|
||||
Buffer.from(seed),
|
||||
config.derivationPath,
|
||||
config.prefix,
|
||||
);
|
||||
case ChainType.BSC:
|
||||
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
|
||||
default:
|
||||
throw new DomainError(`不支持的链类型: ${chainType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static deriveCosmosAddress(
|
||||
seed: Buffer,
|
||||
path: string,
|
||||
prefix: string,
|
||||
): string {
|
||||
const hdkey = HDKey.fromMasterSeed(seed);
|
||||
const childKey = hdkey.derive(path);
|
||||
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
|
||||
|
||||
const hash = createHash('sha256').update(childKey.publicKey).digest();
|
||||
const addressHash = createHash('ripemd160').update(hash).digest();
|
||||
const words = bech32.toWords(addressHash);
|
||||
return bech32.encode(prefix, words);
|
||||
}
|
||||
|
||||
private static deriveEVMAddress(seed: Buffer, path: string): string {
|
||||
const hdkey = HDKey.fromMasterSeed(seed);
|
||||
const childKey = hdkey.derive(path);
|
||||
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
|
||||
|
||||
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
|
||||
return wallet.address;
|
||||
}
|
||||
|
||||
private static validateAddress(
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
): boolean {
|
||||
switch (chainType) {
|
||||
case ChainType.KAVA:
|
||||
case ChainType.BSC:
|
||||
// KAVA 和 BSC 都使用 EVM 地址格式
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
case ChainType.DST:
|
||||
// DST 使用 Cosmos bech32 格式
|
||||
return /^dst1[a-z0-9]{38}$/.test(address);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
FROZEN = 'FROZEN',
|
||||
DEACTIVATED = 'DEACTIVATED',
|
||||
}
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
FROZEN = 'FROZEN',
|
||||
DEACTIVATED = 'DEACTIVATED',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
export enum ChainType {
|
||||
KAVA = 'KAVA',
|
||||
DST = 'DST',
|
||||
BSC = 'BSC',
|
||||
}
|
||||
|
||||
export const CHAIN_CONFIG = {
|
||||
[ChainType.KAVA]: {
|
||||
prefix: 'kava',
|
||||
derivationPath: "m/44'/459'/0'/0/0",
|
||||
},
|
||||
[ChainType.DST]: {
|
||||
prefix: 'dst',
|
||||
derivationPath: "m/44'/118'/0'/0/0",
|
||||
},
|
||||
[ChainType.BSC]: {
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0",
|
||||
},
|
||||
};
|
||||
export enum ChainType {
|
||||
KAVA = 'KAVA',
|
||||
DST = 'DST',
|
||||
BSC = 'BSC',
|
||||
}
|
||||
|
||||
export const CHAIN_CONFIG = {
|
||||
[ChainType.KAVA]: {
|
||||
prefix: 'kava',
|
||||
derivationPath: "m/44'/459'/0'/0/0",
|
||||
},
|
||||
[ChainType.DST]: {
|
||||
prefix: 'dst',
|
||||
derivationPath: "m/44'/118'/0'/0/0",
|
||||
},
|
||||
[ChainType.BSC]: {
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export * from './chain-type.enum';
|
||||
export * from './kyc-status.enum';
|
||||
export * from './account-status.enum';
|
||||
export * from './chain-type.enum';
|
||||
export * from './kyc-status.enum';
|
||||
export * from './account-status.enum';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export enum KYCStatus {
|
||||
NOT_VERIFIED = 'NOT_VERIFIED',
|
||||
PENDING = 'PENDING',
|
||||
VERIFIED = 'VERIFIED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
export enum KYCStatus {
|
||||
NOT_VERIFIED = 'NOT_VERIFIED',
|
||||
PENDING = 'PENDING',
|
||||
VERIFIED = 'VERIFIED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { DomainEvent } from './index';
|
||||
|
||||
export class DeviceAddedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceAdded';
|
||||
}
|
||||
}
|
||||
import { DomainEvent } from './index';
|
||||
|
||||
export class DeviceAddedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceAdded';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly eventId: string;
|
||||
public readonly occurredAt: Date;
|
||||
|
||||
constructor() {
|
||||
this.eventId = uuidv4();
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
abstract get aggregateId(): string;
|
||||
abstract get aggregateType(): string;
|
||||
}
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly eventId: string;
|
||||
public readonly occurredAt: Date;
|
||||
|
||||
constructor() {
|
||||
this.eventId = uuidv4();
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
abstract get aggregateId(): string;
|
||||
abstract get aggregateType(): string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,328 +1,344 @@
|
|||
export abstract class DomainEvent {
|
||||
public readonly occurredAt: Date;
|
||||
public readonly eventId: string;
|
||||
|
||||
constructor() {
|
||||
this.occurredAt = new Date();
|
||||
this.eventId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
}
|
||||
|
||||
export class UserAccountAutoCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||
initialDeviceId: string;
|
||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountAutoCreated';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||
phoneNumber: string;
|
||||
initialDeviceId: string;
|
||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountCreated';
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceAddedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceAdded';
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceRemovedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; deviceId: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceRemoved';
|
||||
}
|
||||
}
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletAddressBoundEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; chainType: string; address: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'WalletAddressBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class MultipleWalletAddressesBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
addresses: Array<{ chainType: string; address: string }>;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MultipleWalletAddressesBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCSubmittedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCSubmitted';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCVerifiedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCVerified';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCRejectedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCRejected';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountFrozenEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountFrozen';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountDeactivatedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountDeactivated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资料更新事件
|
||||
* 当用户更新昵称或头像时发布
|
||||
*/
|
||||
export class UserProfileUpdatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
updatedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserProfileUpdated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MPC 密钥生成请求事件
|
||||
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
||||
*
|
||||
* payload 格式需要与 mpc-service 的 KeygenRequestedPayload 匹配:
|
||||
* - sessionId: 唯一会话ID
|
||||
* - userId: 用户ID
|
||||
* - accountSequence: 8位账户序列号 (用于关联恢复助记词)
|
||||
* - username: 用户名 (用于 mpc-system 标识)
|
||||
* - threshold: 签名阈值 (默认 2)
|
||||
* - totalParties: 总参与方数 (默认 3)
|
||||
* - requireDelegate: 是否需要委托分片 (默认 true)
|
||||
*/
|
||||
export class MpcKeygenRequestedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
username: string;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
requireDelegate: boolean;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MpcKeygenRequested';
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 账户恢复相关事件 ============
|
||||
|
||||
/**
|
||||
* 账户恢复成功事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveredEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
recoveredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecovered';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户恢复失败事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveryFailedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
failureReason: string;
|
||||
deviceId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
attemptedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecoveryFailed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 助记词挂失事件 (审计日志)
|
||||
*/
|
||||
export class MnemonicRevokedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
revokedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MnemonicRevoked';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户解冻事件 (审计日志)
|
||||
*/
|
||||
export class AccountUnfrozenEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
unfrozenAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountUnfrozen';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥轮换请求事件
|
||||
* 触发 MPC 系统进行密钥轮换
|
||||
*/
|
||||
export class KeyRotationRequestedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
requestedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KeyRotationRequested';
|
||||
}
|
||||
}
|
||||
export abstract class DomainEvent {
|
||||
public readonly occurredAt: Date;
|
||||
public readonly eventId: string;
|
||||
|
||||
constructor() {
|
||||
this.occurredAt = new Date();
|
||||
this.eventId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
}
|
||||
|
||||
export class UserAccountAutoCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||
initialDeviceId: string;
|
||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountAutoCreated';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
referralCode: string; // 用户的推荐码(由 identity-service 生成)
|
||||
phoneNumber: string;
|
||||
initialDeviceId: string;
|
||||
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountCreated';
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceAddedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceAdded';
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceRemovedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; deviceId: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'DeviceRemoved';
|
||||
}
|
||||
}
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: { userId: string; phoneNumber: string },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletAddressBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
chainType: string;
|
||||
address: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'WalletAddressBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class MultipleWalletAddressesBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
addresses: Array<{ chainType: string; address: string }>;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MultipleWalletAddressesBound';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCSubmittedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
realName: string;
|
||||
idCardNumber: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCSubmitted';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCVerifiedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCVerified';
|
||||
}
|
||||
}
|
||||
|
||||
export class KYCRejectedEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCRejected';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountFrozenEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; reason: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountFrozen';
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAccountDeactivatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: { userId: string; deactivatedAt: Date },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountDeactivated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资料更新事件
|
||||
* 当用户更新昵称或头像时发布
|
||||
*/
|
||||
export class UserProfileUpdatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
updatedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserProfileUpdated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MPC 密钥生成请求事件
|
||||
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
||||
*
|
||||
* payload 格式需要与 mpc-service 的 KeygenRequestedPayload 匹配:
|
||||
* - sessionId: 唯一会话ID
|
||||
* - userId: 用户ID
|
||||
* - accountSequence: 8位账户序列号 (用于关联恢复助记词)
|
||||
* - username: 用户名 (用于 mpc-system 标识)
|
||||
* - threshold: 签名阈值 (默认 2)
|
||||
* - totalParties: 总参与方数 (默认 3)
|
||||
* - requireDelegate: 是否需要委托分片 (默认 true)
|
||||
*/
|
||||
export class MpcKeygenRequestedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
username: string;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
requireDelegate: boolean;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MpcKeygenRequested';
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 账户恢复相关事件 ============
|
||||
|
||||
/**
|
||||
* 账户恢复成功事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveredEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
recoveredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecovered';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户恢复失败事件 (审计日志)
|
||||
*/
|
||||
export class AccountRecoveryFailedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
accountSequence: string;
|
||||
recoveryMethod: 'mnemonic' | 'phone';
|
||||
failureReason: string;
|
||||
deviceId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
attemptedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountRecoveryFailed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 助记词挂失事件 (审计日志)
|
||||
*/
|
||||
export class MnemonicRevokedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
revokedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'MnemonicRevoked';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户解冻事件 (审计日志)
|
||||
*/
|
||||
export class AccountUnfrozenEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
verifyMethod: 'mnemonic' | 'phone';
|
||||
unfrozenAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'AccountUnfrozen';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥轮换请求事件
|
||||
* 触发 MPC 系统进行密钥轮换
|
||||
*/
|
||||
export class KeyRotationRequestedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
reason: string;
|
||||
requestedAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KeyRotationRequested';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class KYCSubmittedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCSubmitted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'UserAccount';
|
||||
}
|
||||
|
||||
toPayload(): object {
|
||||
return {
|
||||
userId: this.userId,
|
||||
realName: this.realName,
|
||||
idCardNumber: this.idCardNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class KYCSubmittedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KYCSubmitted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'UserAccount';
|
||||
}
|
||||
|
||||
toPayload(): object {
|
||||
return {
|
||||
userId: this.userId,
|
||||
realName: this.realName,
|
||||
idCardNumber: this.idCardNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { DomainEvent } from './index';
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
}
|
||||
import { DomainEvent } from './index';
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: { userId: string; phoneNumber: string },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'UserAccount';
|
||||
}
|
||||
|
||||
toPayload(): object {
|
||||
return {
|
||||
userId: this.userId,
|
||||
phoneNumber: this.phoneNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class PhoneNumberBoundEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phoneNumber: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PhoneNumberBound';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'UserAccount';
|
||||
}
|
||||
|
||||
toPayload(): object {
|
||||
return {
|
||||
userId: this.userId,
|
||||
phoneNumber: this.phoneNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { DomainEvent } from './index';
|
||||
|
||||
export class UserAccountCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
phoneNumber: string;
|
||||
initialDeviceId: string;
|
||||
inviterSequence: number | null;
|
||||
province: string;
|
||||
city: string;
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountCreated';
|
||||
}
|
||||
}
|
||||
import { DomainEvent } from './index';
|
||||
|
||||
export class UserAccountCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly payload: {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
phoneNumber: string;
|
||||
initialDeviceId: string;
|
||||
inviterSequence: number | null;
|
||||
province: string;
|
||||
city: string;
|
||||
registeredAt: Date;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'UserAccountCreated';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from './user-account.repository.interface';
|
||||
export * from './user-account.repository.interface';
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
import { UserId } from '@/domain/value-objects';
|
||||
|
||||
export interface MpcKeyShareData {
|
||||
userId: bigint;
|
||||
publicKey: string;
|
||||
partyIndex: number;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
encryptedShareData: string;
|
||||
}
|
||||
|
||||
export interface MpcKeyShare {
|
||||
shareId: bigint;
|
||||
userId: bigint;
|
||||
publicKey: string;
|
||||
partyIndex: number;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
encryptedShareData: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
rotatedAt: Date | null;
|
||||
}
|
||||
|
||||
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
|
||||
|
||||
export interface MpcKeyShareRepository {
|
||||
/**
|
||||
* 保存服务端 MPC 分片
|
||||
*/
|
||||
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
|
||||
|
||||
/**
|
||||
* 根据用户ID查找分片
|
||||
*/
|
||||
findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
|
||||
|
||||
/**
|
||||
* 根据公钥查找分片
|
||||
*/
|
||||
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
|
||||
|
||||
/**
|
||||
* 更新分片状态 (用于密钥轮换)
|
||||
*/
|
||||
updateStatus(shareId: bigint, status: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 轮换分片 (更新分片数据)
|
||||
*/
|
||||
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
|
||||
}
|
||||
import { UserId } from '@/domain/value-objects';
|
||||
|
||||
export interface MpcKeyShareData {
|
||||
userId: bigint;
|
||||
publicKey: string;
|
||||
partyIndex: number;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
encryptedShareData: string;
|
||||
}
|
||||
|
||||
export interface MpcKeyShare {
|
||||
shareId: bigint;
|
||||
userId: bigint;
|
||||
publicKey: string;
|
||||
partyIndex: number;
|
||||
threshold: number;
|
||||
totalParties: number;
|
||||
encryptedShareData: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
rotatedAt: Date | null;
|
||||
}
|
||||
|
||||
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
|
||||
|
||||
export interface MpcKeyShareRepository {
|
||||
/**
|
||||
* 保存服务端 MPC 分片
|
||||
*/
|
||||
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
|
||||
|
||||
/**
|
||||
* 根据用户ID查找分片
|
||||
*/
|
||||
findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
|
||||
|
||||
/**
|
||||
* 根据公钥查找分片
|
||||
*/
|
||||
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
|
||||
|
||||
/**
|
||||
* 更新分片状态 (用于密钥轮换)
|
||||
*/
|
||||
updateStatus(shareId: bigint, status: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 轮换分片 (更新分片数据)
|
||||
*/
|
||||
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,73 @@
|
|||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import {
|
||||
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus,
|
||||
} from '@/domain/value-objects';
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReferralLinkData {
|
||||
linkId: bigint;
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateReferralLinkParams {
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
}
|
||||
|
||||
export interface UserAccountRepository {
|
||||
save(account: UserAccount): Promise<void>;
|
||||
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
||||
findById(userId: UserId): Promise<UserAccount | null>;
|
||||
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
||||
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
||||
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
||||
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
||||
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>;
|
||||
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
||||
getNextAccountSequence(): Promise<AccountSequence>;
|
||||
findUsers(
|
||||
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; keyword?: string },
|
||||
pagination?: Pagination,
|
||||
): Promise<UserAccount[]>;
|
||||
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
|
||||
|
||||
// 推荐相关
|
||||
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>;
|
||||
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>;
|
||||
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
||||
}
|
||||
|
||||
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import {
|
||||
UserId,
|
||||
AccountSequence,
|
||||
PhoneNumber,
|
||||
ReferralCode,
|
||||
ChainType,
|
||||
AccountStatus,
|
||||
KYCStatus,
|
||||
} from '@/domain/value-objects';
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReferralLinkData {
|
||||
linkId: bigint;
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateReferralLinkParams {
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
}
|
||||
|
||||
export interface UserAccountRepository {
|
||||
save(account: UserAccount): Promise<void>;
|
||||
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
||||
findById(userId: UserId): Promise<UserAccount | null>;
|
||||
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
||||
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
||||
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
||||
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
||||
findByWalletAddress(
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
): Promise<UserAccount | null>;
|
||||
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
||||
getNextAccountSequence(): Promise<AccountSequence>;
|
||||
findUsers(
|
||||
filters?: {
|
||||
status?: AccountStatus;
|
||||
kycStatus?: KYCStatus;
|
||||
keyword?: string;
|
||||
},
|
||||
pagination?: Pagination,
|
||||
): Promise<UserAccount[]>;
|
||||
countUsers(filters?: {
|
||||
status?: AccountStatus;
|
||||
kycStatus?: KYCStatus;
|
||||
}): Promise<number>;
|
||||
|
||||
// 推荐相关
|
||||
findByInviterSequence(
|
||||
inviterSequence: AccountSequence,
|
||||
): Promise<UserAccount[]>;
|
||||
createReferralLink(
|
||||
params: CreateReferralLinkParams,
|
||||
): Promise<ReferralLinkData>;
|
||||
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
||||
}
|
||||
|
||||
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence } from '@/domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class AccountSequenceGeneratorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||
return this.repository.getNextAccountSequence();
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence } from '@/domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class AccountSequenceGeneratorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||
return this.repository.getNextAccountSequence();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,87 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||
|
||||
// ============ ValidationResult ============
|
||||
export class ValidationResult {
|
||||
private constructor(
|
||||
public readonly isValid: boolean,
|
||||
public readonly errorMessage: string | null,
|
||||
) {}
|
||||
|
||||
static success(): ValidationResult {
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
static failure(message: string): ValidationResult {
|
||||
return new ValidationResult(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ AccountSequenceGeneratorService ============
|
||||
@Injectable()
|
||||
export class AccountSequenceGeneratorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||
return this.repository.getNextAccountSequence();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ UserValidatorService ============
|
||||
@Injectable()
|
||||
export class UserValidatorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||
return ValidationResult.success();
|
||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||
// return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import {
|
||||
AccountSequence,
|
||||
PhoneNumber,
|
||||
ReferralCode,
|
||||
ChainType,
|
||||
} from '@/domain/value-objects';
|
||||
|
||||
// ============ ValidationResult ============
|
||||
export class ValidationResult {
|
||||
private constructor(
|
||||
public readonly isValid: boolean,
|
||||
public readonly errorMessage: string | null,
|
||||
) {}
|
||||
|
||||
static success(): ValidationResult {
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
static failure(message: string): ValidationResult {
|
||||
return new ValidationResult(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ AccountSequenceGeneratorService ============
|
||||
@Injectable()
|
||||
export class AccountSequenceGeneratorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async generateNextUserSequence(): Promise<AccountSequence> {
|
||||
return this.repository.getNextAccountSequence();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ UserValidatorService ============
|
||||
@Injectable()
|
||||
export class UserValidatorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async validatePhoneNumber(
|
||||
phoneNumber: PhoneNumber,
|
||||
): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||
return ValidationResult.success();
|
||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||
// return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateReferralCode(
|
||||
referralCode: ReferralCode,
|
||||
): Promise<ValidationResult> {
|
||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||
if (!inviter.isActive)
|
||||
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateWalletAddress(
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByWalletAddress(
|
||||
chainType,
|
||||
address,
|
||||
);
|
||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,67 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||
|
||||
export class ValidationResult {
|
||||
private constructor(
|
||||
public readonly isValid: boolean,
|
||||
public readonly errorMessage: string | null,
|
||||
) {}
|
||||
|
||||
static success(): ValidationResult {
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
static failure(message: string): ValidationResult {
|
||||
return new ValidationResult(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserValidatorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||
return ValidationResult.success();
|
||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||
// return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByWalletAddress(chainType, address);
|
||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
}
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import {
|
||||
UserAccountRepository,
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||
|
||||
export class ValidationResult {
|
||||
private constructor(
|
||||
public readonly isValid: boolean,
|
||||
public readonly errorMessage: string | null,
|
||||
) {}
|
||||
|
||||
static success(): ValidationResult {
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
static failure(message: string): ValidationResult {
|
||||
return new ValidationResult(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserValidatorService {
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly repository: UserAccountRepository,
|
||||
) {}
|
||||
|
||||
async validatePhoneNumber(
|
||||
phoneNumber: PhoneNumber,
|
||||
): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async checkDeviceNotRegistered(deviceId: string): Promise<ValidationResult> {
|
||||
// TODO: 暂时禁用设备检查,允许同一设备创建多个账户
|
||||
return ValidationResult.success();
|
||||
// const existing = await this.repository.findByDeviceId(deviceId);
|
||||
// if (existing) return ValidationResult.failure('该设备已创建过账户');
|
||||
// return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateReferralCode(
|
||||
referralCode: ReferralCode,
|
||||
): Promise<ValidationResult> {
|
||||
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||
if (!inviter.isActive)
|
||||
return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
async validateWalletAddress(
|
||||
chainType: ChainType,
|
||||
address: string,
|
||||
): Promise<ValidationResult> {
|
||||
const existing = await this.repository.findByWalletAddress(
|
||||
chainType,
|
||||
address,
|
||||
);
|
||||
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||
return ValidationResult.success();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,60 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
/**
|
||||
* 账户序列号值对象
|
||||
* 格式: D + 年(2位) + 月(2位) + 日(2位) + 5位序号
|
||||
* 示例: D2512110008 -> 2025年12月11日的第8个注册用户
|
||||
*/
|
||||
export class AccountSequence {
|
||||
private static readonly PATTERN = /^D\d{11}$/;
|
||||
|
||||
constructor(public readonly value: string) {
|
||||
if (!AccountSequence.PATTERN.test(value)) {
|
||||
throw new DomainError(`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`);
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: string): AccountSequence {
|
||||
return new AccountSequence(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据日期和当日序号生成新的账户序列号
|
||||
* @param date 日期
|
||||
* @param dailySequence 当日序号 (0-99999)
|
||||
*/
|
||||
static generate(date: Date, dailySequence: number): AccountSequence {
|
||||
if (dailySequence < 0 || dailySequence > 99999) {
|
||||
throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`);
|
||||
}
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const seq = String(dailySequence).padStart(5, '0');
|
||||
return new AccountSequence(`D${year}${month}${day}${seq}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取日期字符串 (YYMMDD)
|
||||
*/
|
||||
get dateString(): string {
|
||||
return this.value.slice(1, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取当日序号
|
||||
*/
|
||||
get dailySequence(): number {
|
||||
return parseInt(this.value.slice(7), 10);
|
||||
}
|
||||
|
||||
equals(other: AccountSequence): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
/**
|
||||
* 账户序列号值对象
|
||||
* 格式: D + 年(2位) + 月(2位) + 日(2位) + 5位序号
|
||||
* 示例: D2512110008 -> 2025年12月11日的第8个注册用户
|
||||
*/
|
||||
export class AccountSequence {
|
||||
private static readonly PATTERN = /^D\d{11}$/;
|
||||
|
||||
constructor(public readonly value: string) {
|
||||
if (!AccountSequence.PATTERN.test(value)) {
|
||||
throw new DomainError(
|
||||
`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: string): AccountSequence {
|
||||
return new AccountSequence(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据日期和当日序号生成新的账户序列号
|
||||
* @param date 日期
|
||||
* @param dailySequence 当日序号 (0-99999)
|
||||
*/
|
||||
static generate(date: Date, dailySequence: number): AccountSequence {
|
||||
if (dailySequence < 0 || dailySequence > 99999) {
|
||||
throw new DomainError(`当日序号超出范围: ${dailySequence},应为 0-99999`);
|
||||
}
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const seq = String(dailySequence).padStart(5, '0');
|
||||
return new AccountSequence(`D${year}${month}${day}${seq}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取日期字符串 (YYMMDD)
|
||||
*/
|
||||
get dateString(): string {
|
||||
return this.value.slice(1, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取当日序号
|
||||
*/
|
||||
get dailySequence(): number {
|
||||
return parseInt(this.value.slice(7), 10);
|
||||
}
|
||||
|
||||
equals(other: AccountSequence): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
export class DeviceInfo {
|
||||
private _lastActiveAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName: string,
|
||||
public readonly addedAt: Date,
|
||||
lastActiveAt: Date,
|
||||
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
||||
) {
|
||||
this._lastActiveAt = lastActiveAt;
|
||||
}
|
||||
|
||||
get lastActiveAt(): Date {
|
||||
return this._lastActiveAt;
|
||||
}
|
||||
|
||||
updateActivity(): void {
|
||||
this._lastActiveAt = new Date();
|
||||
}
|
||||
}
|
||||
export class DeviceInfo {
|
||||
private _lastActiveAt: Date;
|
||||
|
||||
constructor(
|
||||
public readonly deviceId: string,
|
||||
public readonly deviceName: string,
|
||||
public readonly addedAt: Date,
|
||||
lastActiveAt: Date,
|
||||
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
|
||||
) {
|
||||
this._lastActiveAt = lastActiveAt;
|
||||
}
|
||||
|
||||
get lastActiveAt(): Date {
|
||||
return this._lastActiveAt;
|
||||
}
|
||||
|
||||
updateActivity(): void {
|
||||
this._lastActiveAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
||||
import {
|
||||
createHash,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
scryptSync,
|
||||
} from 'crypto';
|
||||
import * as bip39 from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
|
||||
|
|
@ -144,7 +150,9 @@ export class DeviceInfo {
|
|||
}
|
||||
|
||||
get deviceModel(): string | undefined {
|
||||
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined;
|
||||
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as
|
||||
| string
|
||||
| undefined;
|
||||
}
|
||||
|
||||
get osVersion(): string | undefined {
|
||||
|
|
@ -188,13 +196,27 @@ export class KYCInfo {
|
|||
if (!realName || realName.length < 2) {
|
||||
throw new DomainError('真实姓名不合法');
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
||||
if (
|
||||
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||
idCardNumber,
|
||||
)
|
||||
) {
|
||||
throw new DomainError('身份证号格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
||||
static create(params: {
|
||||
realName: string;
|
||||
idCardNumber: string;
|
||||
idCardFrontUrl: string;
|
||||
idCardBackUrl: string;
|
||||
}): KYCInfo {
|
||||
return new KYCInfo(
|
||||
params.realName,
|
||||
params.idCardNumber,
|
||||
params.idCardFrontUrl,
|
||||
params.idCardBackUrl,
|
||||
);
|
||||
}
|
||||
|
||||
maskedIdCardNumber(): string {
|
||||
|
|
@ -255,7 +277,11 @@ export class MnemonicEncryption {
|
|||
static decrypt(encryptedData: string, key: string): string {
|
||||
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
|
||||
const derivedKey = this.deriveKey(key);
|
||||
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex'));
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
derivedKey,
|
||||
Buffer.from(iv, 'hex'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
|
|
|
|||
|
|
@ -1,25 +1,39 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class KYCInfo {
|
||||
constructor(
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
public readonly idCardFrontUrl: string,
|
||||
public readonly idCardBackUrl: string,
|
||||
) {
|
||||
if (!realName || realName.length < 2) {
|
||||
throw new DomainError('真实姓名不合法');
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) {
|
||||
throw new DomainError('身份证号格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
||||
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
||||
}
|
||||
|
||||
maskedIdCardNumber(): string {
|
||||
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class KYCInfo {
|
||||
constructor(
|
||||
public readonly realName: string,
|
||||
public readonly idCardNumber: string,
|
||||
public readonly idCardFrontUrl: string,
|
||||
public readonly idCardBackUrl: string,
|
||||
) {
|
||||
if (!realName || realName.length < 2) {
|
||||
throw new DomainError('真实姓名不合法');
|
||||
}
|
||||
if (
|
||||
!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(
|
||||
idCardNumber,
|
||||
)
|
||||
) {
|
||||
throw new DomainError('身份证号格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
realName: string;
|
||||
idCardNumber: string;
|
||||
idCardFrontUrl: string;
|
||||
idCardBackUrl: string;
|
||||
}): KYCInfo {
|
||||
return new KYCInfo(
|
||||
params.realName,
|
||||
params.idCardNumber,
|
||||
params.idCardFrontUrl,
|
||||
params.idCardBackUrl,
|
||||
);
|
||||
}
|
||||
|
||||
maskedIdCardNumber(): string {
|
||||
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +1,118 @@
|
|||
import { Mnemonic } from './index';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
describe('Mnemonic ValueObject', () => {
|
||||
describe('generate', () => {
|
||||
it('应该生成有效的12个单词助记词', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic).toBeDefined();
|
||||
expect(mnemonic.value).toBeDefined();
|
||||
|
||||
const words = mnemonic.getWords();
|
||||
expect(words).toHaveLength(12);
|
||||
expect(words.every(word => word.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('生成的助记词应该能转换为 seed', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const seed = mnemonic.toSeed();
|
||||
|
||||
expect(seed).toBeDefined();
|
||||
expect(seed).toBeInstanceOf(Uint8Array);
|
||||
expect(seed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每次生成的助记词应该不同', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic1.value).not.toBe(mnemonic2.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该接受有效的助记词字符串', () => {
|
||||
const validMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic = Mnemonic.create(validMnemonic);
|
||||
|
||||
expect(mnemonic.value).toBe(validMnemonic);
|
||||
});
|
||||
|
||||
it('应该拒绝无效的助记词', () => {
|
||||
const invalidMnemonic = 'invalid invalid invalid';
|
||||
|
||||
expect(() => {
|
||||
Mnemonic.create(invalidMnemonic);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
|
||||
it('应该拒绝空字符串', () => {
|
||||
expect(() => {
|
||||
Mnemonic.create('');
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
|
||||
it('应该拒绝非英文单词', () => {
|
||||
const invalidMnemonic = '中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
||||
|
||||
expect(() => {
|
||||
Mnemonic.create(invalidMnemonic);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWords', () => {
|
||||
it('应该返回单词数组', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const words = mnemonic.getWords();
|
||||
|
||||
expect(Array.isArray(words)).toBe(true);
|
||||
expect(words.length).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSeed', () => {
|
||||
it('相同的助记词应该生成相同的 seed', () => {
|
||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||
|
||||
const seed1 = mnemonic1.toSeed();
|
||||
const seed2 = mnemonic2.toSeed();
|
||||
|
||||
expect(seed1).toEqual(seed2);
|
||||
});
|
||||
|
||||
it('不同的助记词应该生成不同的 seed', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
const seed1 = mnemonic1.toSeed();
|
||||
const seed2 = mnemonic2.toSeed();
|
||||
|
||||
expect(seed1).not.toEqual(seed2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('相同的助记词应该相等', () => {
|
||||
const mnemonicStr = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||
|
||||
expect(mnemonic1.equals(mnemonic2)).toBe(true);
|
||||
});
|
||||
|
||||
it('不同的助记词应该不相等', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic1.equals(mnemonic2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { Mnemonic } from './index';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
describe('Mnemonic ValueObject', () => {
|
||||
describe('generate', () => {
|
||||
it('应该生成有效的12个单词助记词', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic).toBeDefined();
|
||||
expect(mnemonic.value).toBeDefined();
|
||||
|
||||
const words = mnemonic.getWords();
|
||||
expect(words).toHaveLength(12);
|
||||
expect(words.every((word) => word.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('生成的助记词应该能转换为 seed', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const seed = mnemonic.toSeed();
|
||||
|
||||
expect(seed).toBeDefined();
|
||||
expect(seed).toBeInstanceOf(Uint8Array);
|
||||
expect(seed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每次生成的助记词应该不同', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic1.value).not.toBe(mnemonic2.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该接受有效的助记词字符串', () => {
|
||||
const validMnemonic =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic = Mnemonic.create(validMnemonic);
|
||||
|
||||
expect(mnemonic.value).toBe(validMnemonic);
|
||||
});
|
||||
|
||||
it('应该拒绝无效的助记词', () => {
|
||||
const invalidMnemonic = 'invalid invalid invalid';
|
||||
|
||||
expect(() => {
|
||||
Mnemonic.create(invalidMnemonic);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
|
||||
it('应该拒绝空字符串', () => {
|
||||
expect(() => {
|
||||
Mnemonic.create('');
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
|
||||
it('应该拒绝非英文单词', () => {
|
||||
const invalidMnemonic =
|
||||
'中文 助记词 测试 中文 助记词 测试 中文 助记词 测试 中文 助记词';
|
||||
|
||||
expect(() => {
|
||||
Mnemonic.create(invalidMnemonic);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWords', () => {
|
||||
it('应该返回单词数组', () => {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const words = mnemonic.getWords();
|
||||
|
||||
expect(Array.isArray(words)).toBe(true);
|
||||
expect(words.length).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSeed', () => {
|
||||
it('相同的助记词应该生成相同的 seed', () => {
|
||||
const mnemonicStr =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||
|
||||
const seed1 = mnemonic1.toSeed();
|
||||
const seed2 = mnemonic2.toSeed();
|
||||
|
||||
expect(seed1).toEqual(seed2);
|
||||
});
|
||||
|
||||
it('不同的助记词应该生成不同的 seed', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
const seed1 = mnemonic1.toSeed();
|
||||
const seed2 = mnemonic2.toSeed();
|
||||
|
||||
expect(seed1).not.toEqual(seed2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('相同的助记词应该相等', () => {
|
||||
const mnemonicStr =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const mnemonic1 = Mnemonic.create(mnemonicStr);
|
||||
const mnemonic2 = Mnemonic.create(mnemonicStr);
|
||||
|
||||
expect(mnemonic1.equals(mnemonic2)).toBe(true);
|
||||
});
|
||||
|
||||
it('不同的助记词应该不相等', () => {
|
||||
const mnemonic1 = Mnemonic.generate();
|
||||
const mnemonic2 = Mnemonic.generate();
|
||||
|
||||
expect(mnemonic1.equals(mnemonic2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import * as bip39 from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
|
||||
export class Mnemonic {
|
||||
constructor(public readonly value: string) {
|
||||
if (!bip39.validateMnemonic(value, wordlist)) {
|
||||
throw new DomainError('助记词格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static generate(): Mnemonic {
|
||||
const mnemonic = bip39.generateMnemonic(wordlist, 128);
|
||||
return new Mnemonic(mnemonic);
|
||||
}
|
||||
|
||||
static create(value: string): Mnemonic {
|
||||
return new Mnemonic(value);
|
||||
}
|
||||
|
||||
toSeed(): Uint8Array {
|
||||
return bip39.mnemonicToSeedSync(this.value);
|
||||
}
|
||||
|
||||
getWords(): string[] {
|
||||
return this.value.split(' ');
|
||||
}
|
||||
|
||||
equals(other: Mnemonic): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
import * as bip39 from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
|
||||
export class Mnemonic {
|
||||
constructor(public readonly value: string) {
|
||||
if (!bip39.validateMnemonic(value, wordlist)) {
|
||||
throw new DomainError('助记词格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static generate(): Mnemonic {
|
||||
const mnemonic = bip39.generateMnemonic(wordlist, 128);
|
||||
return new Mnemonic(mnemonic);
|
||||
}
|
||||
|
||||
static create(value: string): Mnemonic {
|
||||
return new Mnemonic(value);
|
||||
}
|
||||
|
||||
toSeed(): Uint8Array {
|
||||
return bip39.mnemonicToSeedSync(this.value);
|
||||
}
|
||||
|
||||
getWords(): string[] {
|
||||
return this.value.split(' ');
|
||||
}
|
||||
|
||||
equals(other: Mnemonic): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,90 @@
|
|||
import { PhoneNumber } from './index';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
describe('PhoneNumber ValueObject', () => {
|
||||
describe('create', () => {
|
||||
it('应该接受有效的中国手机号', () => {
|
||||
const validPhones = [
|
||||
'13800138000',
|
||||
'13912345678',
|
||||
'15800001111',
|
||||
'18600002222',
|
||||
'19900003333',
|
||||
];
|
||||
|
||||
validPhones.forEach(phone => {
|
||||
const phoneNumber = PhoneNumber.create(phone);
|
||||
expect(phoneNumber.value).toBe(phone);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝无效的手机号格式', () => {
|
||||
const invalidPhones = [
|
||||
'12800138000', // 不是1开头
|
||||
'1380013800', // 少于11位
|
||||
'138001380000', // 多于11位
|
||||
'10800138000', // 第二位不是3-9
|
||||
'abcdefghijk', // 非数字
|
||||
'', // 空字符串
|
||||
];
|
||||
|
||||
invalidPhones.forEach(phone => {
|
||||
expect(() => {
|
||||
PhoneNumber.create(phone);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝包含特殊字符的手机号', () => {
|
||||
const invalidPhones = [
|
||||
'138-0013-8000',
|
||||
'138 0013 8000',
|
||||
'+8613800138000',
|
||||
];
|
||||
|
||||
invalidPhones.forEach(phone => {
|
||||
expect(() => {
|
||||
PhoneNumber.create(phone);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('masked', () => {
|
||||
it('应该正确掩码手机号', () => {
|
||||
const phoneNumber = PhoneNumber.create('13800138000');
|
||||
const masked = phoneNumber.masked();
|
||||
|
||||
expect(masked).toBe('138****8000');
|
||||
});
|
||||
|
||||
it('掩码后应该隐藏中间4位', () => {
|
||||
const testCases = [
|
||||
{ input: '13912345678', expected: '139****5678' },
|
||||
{ input: '15800001111', expected: '158****1111' },
|
||||
{ input: '18600002222', expected: '186****2222' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const phoneNumber = PhoneNumber.create(input);
|
||||
expect(phoneNumber.masked()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('相同的手机号应该相等', () => {
|
||||
const phone1 = PhoneNumber.create('13800138000');
|
||||
const phone2 = PhoneNumber.create('13800138000');
|
||||
|
||||
expect(phone1.equals(phone2)).toBe(true);
|
||||
});
|
||||
|
||||
it('不同的手机号应该不相等', () => {
|
||||
const phone1 = PhoneNumber.create('13800138000');
|
||||
const phone2 = PhoneNumber.create('13912345678');
|
||||
|
||||
expect(phone1.equals(phone2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { PhoneNumber } from './index';
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
describe('PhoneNumber ValueObject', () => {
|
||||
describe('create', () => {
|
||||
it('应该接受有效的中国手机号', () => {
|
||||
const validPhones = [
|
||||
'13800138000',
|
||||
'13912345678',
|
||||
'15800001111',
|
||||
'18600002222',
|
||||
'19900003333',
|
||||
];
|
||||
|
||||
validPhones.forEach((phone) => {
|
||||
const phoneNumber = PhoneNumber.create(phone);
|
||||
expect(phoneNumber.value).toBe(phone);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝无效的手机号格式', () => {
|
||||
const invalidPhones = [
|
||||
'12800138000', // 不是1开头
|
||||
'1380013800', // 少于11位
|
||||
'138001380000', // 多于11位
|
||||
'10800138000', // 第二位不是3-9
|
||||
'abcdefghijk', // 非数字
|
||||
'', // 空字符串
|
||||
];
|
||||
|
||||
invalidPhones.forEach((phone) => {
|
||||
expect(() => {
|
||||
PhoneNumber.create(phone);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝包含特殊字符的手机号', () => {
|
||||
const invalidPhones = [
|
||||
'138-0013-8000',
|
||||
'138 0013 8000',
|
||||
'+8613800138000',
|
||||
];
|
||||
|
||||
invalidPhones.forEach((phone) => {
|
||||
expect(() => {
|
||||
PhoneNumber.create(phone);
|
||||
}).toThrow(DomainError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('masked', () => {
|
||||
it('应该正确掩码手机号', () => {
|
||||
const phoneNumber = PhoneNumber.create('13800138000');
|
||||
const masked = phoneNumber.masked();
|
||||
|
||||
expect(masked).toBe('138****8000');
|
||||
});
|
||||
|
||||
it('掩码后应该隐藏中间4位', () => {
|
||||
const testCases = [
|
||||
{ input: '13912345678', expected: '139****5678' },
|
||||
{ input: '15800001111', expected: '158****1111' },
|
||||
{ input: '18600002222', expected: '186****2222' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const phoneNumber = PhoneNumber.create(input);
|
||||
expect(phoneNumber.masked()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('相同的手机号应该相等', () => {
|
||||
const phone1 = PhoneNumber.create('13800138000');
|
||||
const phone2 = PhoneNumber.create('13800138000');
|
||||
|
||||
expect(phone1.equals(phone2)).toBe(true);
|
||||
});
|
||||
|
||||
it('不同的手机号应该不相等', () => {
|
||||
const phone1 = PhoneNumber.create('13800138000');
|
||||
const phone2 = PhoneNumber.create('13912345678');
|
||||
|
||||
expect(phone1.equals(phone2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class PhoneNumber {
|
||||
constructor(public readonly value: string) {
|
||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
throw new DomainError('手机号格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: string): PhoneNumber {
|
||||
return new PhoneNumber(value);
|
||||
}
|
||||
|
||||
equals(other: PhoneNumber): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
masked(): string {
|
||||
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class PhoneNumber {
|
||||
constructor(public readonly value: string) {
|
||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
throw new DomainError('手机号格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: string): PhoneNumber {
|
||||
return new PhoneNumber(value);
|
||||
}
|
||||
|
||||
equals(other: PhoneNumber): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
masked(): string {
|
||||
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class ReferralCode {
|
||||
constructor(public readonly value: string) {
|
||||
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
|
||||
if (!/^[A-Z0-9]{6,20}$/.test(value)) {
|
||||
throw new DomainError('推荐码格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static generate(): ReferralCode {
|
||||
// 生成6位随机推荐码(identity-service 本地生成)
|
||||
// 注:referral-service 会生成10位的推荐码,两者都兼容
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return new ReferralCode(code);
|
||||
}
|
||||
|
||||
static create(value: string): ReferralCode {
|
||||
return new ReferralCode(value.toUpperCase());
|
||||
}
|
||||
|
||||
equals(other: ReferralCode): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||
|
||||
export class ReferralCode {
|
||||
constructor(public readonly value: string) {
|
||||
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
|
||||
if (!/^[A-Z0-9]{6,20}$/.test(value)) {
|
||||
throw new DomainError('推荐码格式错误');
|
||||
}
|
||||
}
|
||||
|
||||
static generate(): ReferralCode {
|
||||
// 生成6位随机推荐码(identity-service 本地生成)
|
||||
// 注:referral-service 会生成10位的推荐码,两者都兼容
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return new ReferralCode(code);
|
||||
}
|
||||
|
||||
static create(value: string): ReferralCode {
|
||||
return new ReferralCode(value.toUpperCase());
|
||||
}
|
||||
|
||||
equals(other: ReferralCode): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export interface VerifyMnemonicResult {
|
|||
}
|
||||
|
||||
export interface VerifyMnemonicByAccountParams {
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
|
|
@ -60,8 +60,12 @@ export class BlockchainClientService {
|
|||
/**
|
||||
* 验证助记词是否匹配指定的钱包地址
|
||||
*/
|
||||
async verifyMnemonic(params: VerifyMnemonicParams): Promise<VerifyMnemonicResult> {
|
||||
this.logger.log(`Verifying mnemonic against ${params.expectedAddresses.length} addresses`);
|
||||
async verifyMnemonic(
|
||||
params: VerifyMnemonicParams,
|
||||
): Promise<VerifyMnemonicResult> {
|
||||
this.logger.log(
|
||||
`Verifying mnemonic against ${params.expectedAddresses.length} addresses`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
|
|
@ -78,7 +82,9 @@ export class BlockchainClientService {
|
|||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
||||
this.logger.log(
|
||||
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to verify mnemonic', error);
|
||||
|
|
@ -89,7 +95,9 @@ export class BlockchainClientService {
|
|||
/**
|
||||
* 通过账户序列号验证助记词(用于账户恢复)
|
||||
*/
|
||||
async verifyMnemonicByAccount(params: VerifyMnemonicByAccountParams): Promise<VerifyMnemonicHashResult> {
|
||||
async verifyMnemonicByAccount(
|
||||
params: VerifyMnemonicByAccountParams,
|
||||
): Promise<VerifyMnemonicHashResult> {
|
||||
this.logger.log(`Verifying mnemonic for account ${params.accountSequence}`);
|
||||
|
||||
try {
|
||||
|
|
@ -107,7 +115,9 @@ export class BlockchainClientService {
|
|||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Mnemonic verification result: valid=${response.data.valid}`);
|
||||
this.logger.log(
|
||||
`Mnemonic verification result: valid=${response.data.valid}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to verify mnemonic', error);
|
||||
|
|
@ -133,7 +143,9 @@ export class BlockchainClientService {
|
|||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Derived ${response.data.addresses.length} addresses from mnemonic`);
|
||||
this.logger.log(
|
||||
`Derived ${response.data.addresses.length} addresses from mnemonic`,
|
||||
);
|
||||
return response.data.addresses;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to derive addresses from mnemonic', error);
|
||||
|
|
@ -145,7 +157,9 @@ export class BlockchainClientService {
|
|||
* 标记助记词已备份
|
||||
*/
|
||||
async markMnemonicBackedUp(accountSequence: string): Promise<void> {
|
||||
this.logger.log(`Marking mnemonic as backed up for account ${accountSequence}`);
|
||||
this.logger.log(
|
||||
`Marking mnemonic as backed up for account ${accountSequence}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
|
|
@ -159,7 +173,9 @@ export class BlockchainClientService {
|
|||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Mnemonic marked as backed up for account ${accountSequence}`);
|
||||
this.logger.log(
|
||||
`Mnemonic marked as backed up for account ${accountSequence}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to mark mnemonic as backed up', error);
|
||||
throw error;
|
||||
|
|
@ -169,8 +185,13 @@ export class BlockchainClientService {
|
|||
/**
|
||||
* 挂失助记词
|
||||
*/
|
||||
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`);
|
||||
async revokeMnemonic(
|
||||
accountSequence: string,
|
||||
reason: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(
|
||||
`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
|
|
@ -184,7 +205,9 @@ export class BlockchainClientService {
|
|||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Mnemonic revoke result: success=${response.data.success}`);
|
||||
this.logger.log(
|
||||
`Mnemonic revoke result: success=${response.data.success}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to revoke mnemonic', error);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export * from './mpc.module';
|
||||
export * from './mpc-client.service';
|
||||
export * from './mpc-wallet.service';
|
||||
export * from './mpc.module';
|
||||
export * from './mpc-client.service';
|
||||
export * from './mpc-wallet.service';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,250 +1,286 @@
|
|||
/**
|
||||
* MPC Wallet Service
|
||||
*
|
||||
* 使用 MPC 2-of-3 协议生成三链钱包地址
|
||||
* 并对地址进行签名验证
|
||||
*
|
||||
* 调用路径: identity-service → mpc-service → mpc-system
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { MpcClientService } from './mpc-client.service';
|
||||
|
||||
export interface MpcWalletGenerationParams {
|
||||
userId: string;
|
||||
username: string; // 用户名 (用于 MPC keygen)
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ChainWalletInfo {
|
||||
chainType: 'KAVA' | 'DST' | 'BSC';
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
signature: string; // 64 bytes hex (R + S)
|
||||
}
|
||||
|
||||
export interface MpcWalletGenerationResult {
|
||||
publicKey: string; // MPC 公钥
|
||||
delegateShare: string; // delegate share (加密的用户分片)
|
||||
serverParties: string[]; // 服务器 party IDs
|
||||
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
||||
sessionId: string; // MPC 会话ID
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MpcWalletService {
|
||||
private readonly logger = new Logger(MpcWalletService.name);
|
||||
|
||||
// 三条链的地址生成配置
|
||||
private readonly chainConfigs = {
|
||||
BSC: {
|
||||
name: 'Binance Smart Chain',
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
|
||||
addressType: 'evm' as const,
|
||||
},
|
||||
KAVA: {
|
||||
name: 'Kava EVM',
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
|
||||
addressType: 'evm' as const,
|
||||
},
|
||||
DST: {
|
||||
name: 'Durian Star Token',
|
||||
prefix: 'dst', // Cosmos Bech32 前缀
|
||||
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
||||
addressType: 'cosmos' as const,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly mpcClient: MpcClientService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 使用 MPC 2-of-3 生成三链钱包
|
||||
*
|
||||
* 流程:
|
||||
* 1. 生成 MPC 密钥 (2-of-3)
|
||||
* 2. 从公钥派生三条链的地址
|
||||
* 3. 计算地址摘要
|
||||
* 4. 使用 MPC 签名对摘要进行签名
|
||||
* 5. 返回完整的钱包信息
|
||||
*/
|
||||
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> {
|
||||
this.logger.log(`Generating MPC wallet for user=${params.userId}, username=${params.username}`);
|
||||
|
||||
// Step 1: 生成 MPC 密钥
|
||||
const keygenResult = await this.mpcClient.executeKeygen({
|
||||
sessionId: this.mpcClient.generateSessionId(),
|
||||
username: params.username,
|
||||
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
||||
totalParties: 3,
|
||||
requireDelegate: true,
|
||||
});
|
||||
|
||||
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`);
|
||||
|
||||
// Step 2: 从公钥派生三条链的地址
|
||||
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey);
|
||||
|
||||
// Step 3: 计算地址摘要
|
||||
const addressDigest = this.computeAddressDigest(walletAddresses);
|
||||
|
||||
// Step 4: 使用 MPC 签名对摘要进行签名
|
||||
const signingResult = await this.mpcClient.executeSigning({
|
||||
username: params.username,
|
||||
messageHash: addressDigest,
|
||||
});
|
||||
|
||||
this.logger.log(`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`);
|
||||
|
||||
// Step 5: 构建钱包信息
|
||||
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
||||
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
|
||||
address: wa.address,
|
||||
publicKey: keygenResult.publicKey,
|
||||
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
|
||||
signature: signingResult.signature,
|
||||
}));
|
||||
|
||||
return {
|
||||
publicKey: keygenResult.publicKey,
|
||||
delegateShare: keygenResult.delegateShare.encryptedShare,
|
||||
serverParties: keygenResult.serverParties,
|
||||
wallets,
|
||||
sessionId: keygenResult.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证钱包地址签名
|
||||
*
|
||||
* 用于检测地址是否被篡改
|
||||
*
|
||||
* @param address 钱包地址
|
||||
* @param chainType 链类型
|
||||
* @param publicKey 公钥 (hex)
|
||||
* @param signature 签名 (64 bytes hex: R + S)
|
||||
*/
|
||||
async verifyWalletSignature(
|
||||
address: string,
|
||||
chainType: string,
|
||||
publicKey: string,
|
||||
signature: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { ethers } = await import('ethers');
|
||||
|
||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
||||
if (signature.length !== 128) {
|
||||
this.logger.error(`Invalid signature length: ${signature.length}, expected 128`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = '0x' + signature.slice(0, 64);
|
||||
const s = '0x' + signature.slice(64, 128);
|
||||
|
||||
// 计算地址摘要
|
||||
const digest = this.computeSingleAddressDigest(address, chainType);
|
||||
const digestBytes = Buffer.from(digest, 'hex');
|
||||
|
||||
// 尝试两种 recovery id
|
||||
for (const v of [27, 28]) {
|
||||
try {
|
||||
const sig = ethers.Signature.from({ r, s, v });
|
||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
||||
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
||||
|
||||
if (compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 尝试下一个 v 值
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.logger.error(`Signature verification failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MPC 公钥派生三条链的地址
|
||||
*
|
||||
* - BSC/KAVA: EVM 地址 (keccak256)
|
||||
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
||||
*/
|
||||
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> {
|
||||
const { ethers } = await import('ethers');
|
||||
const { bech32 } = await import('bech32');
|
||||
|
||||
// MPC 公钥 (压缩格式,33 bytes)
|
||||
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
||||
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex');
|
||||
|
||||
// 解压公钥 (如果是压缩格式)
|
||||
let uncompressedPubKey: string;
|
||||
if (pubKeyHex.length === 68) {
|
||||
// 压缩格式 (33 bytes = 66 hex chars + 0x)
|
||||
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
|
||||
} else {
|
||||
uncompressedPubKey = pubKeyHex;
|
||||
}
|
||||
|
||||
// ===== EVM 地址派生 (BSC, KAVA) =====
|
||||
// 地址 = keccak256(公钥[1:])[12:]
|
||||
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
|
||||
const addressHash = ethers.keccak256(pubKeyBytes);
|
||||
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
|
||||
|
||||
// ===== Cosmos 地址派生 (DST) =====
|
||||
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
||||
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest();
|
||||
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
||||
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash));
|
||||
|
||||
return [
|
||||
{ chainType: 'BSC', address: evmAddress },
|
||||
{ chainType: 'KAVA', address: evmAddress },
|
||||
{ chainType: 'DST', address: dstAddress },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算三个地址的联合摘要
|
||||
*
|
||||
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
||||
*/
|
||||
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string {
|
||||
// 按链类型排序以确保一致性
|
||||
const sortedAddresses = [...addresses].sort((a, b) =>
|
||||
a.chainType.localeCompare(b.chainType),
|
||||
);
|
||||
|
||||
// 拼接地址
|
||||
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join('');
|
||||
|
||||
// 计算 SHA256 摘要
|
||||
return createHash('sha256').update(concatenated).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单个地址的摘要
|
||||
*/
|
||||
private computeSingleAddressDigest(address: string, chainType: string): string {
|
||||
const message = `${chainType}:${address.toLowerCase()}`;
|
||||
return createHash('sha256').update(message).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的链类型
|
||||
*/
|
||||
getSupportedChains(): string[] {
|
||||
return Object.keys(this.chainConfigs);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* MPC Wallet Service
|
||||
*
|
||||
* 使用 MPC 2-of-3 协议生成三链钱包地址
|
||||
* 并对地址进行签名验证
|
||||
*
|
||||
* 调用路径: identity-service → mpc-service → mpc-system
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { MpcClientService } from './mpc-client.service';
|
||||
|
||||
export interface MpcWalletGenerationParams {
|
||||
userId: string;
|
||||
username: string; // 用户名 (用于 MPC keygen)
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ChainWalletInfo {
|
||||
chainType: 'KAVA' | 'DST' | 'BSC';
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
signature: string; // 64 bytes hex (R + S)
|
||||
}
|
||||
|
||||
export interface MpcWalletGenerationResult {
|
||||
publicKey: string; // MPC 公钥
|
||||
delegateShare: string; // delegate share (加密的用户分片)
|
||||
serverParties: string[]; // 服务器 party IDs
|
||||
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
||||
sessionId: string; // MPC 会话ID
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MpcWalletService {
|
||||
private readonly logger = new Logger(MpcWalletService.name);
|
||||
|
||||
// 三条链的地址生成配置
|
||||
private readonly chainConfigs = {
|
||||
BSC: {
|
||||
name: 'Binance Smart Chain',
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
|
||||
addressType: 'evm' as const,
|
||||
},
|
||||
KAVA: {
|
||||
name: 'Kava EVM',
|
||||
prefix: '0x',
|
||||
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
|
||||
addressType: 'evm' as const,
|
||||
},
|
||||
DST: {
|
||||
name: 'Durian Star Token',
|
||||
prefix: 'dst', // Cosmos Bech32 前缀
|
||||
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
||||
addressType: 'cosmos' as const,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly mpcClient: MpcClientService) {}
|
||||
|
||||
/**
|
||||
* 使用 MPC 2-of-3 生成三链钱包
|
||||
*
|
||||
* 流程:
|
||||
* 1. 生成 MPC 密钥 (2-of-3)
|
||||
* 2. 从公钥派生三条链的地址
|
||||
* 3. 计算地址摘要
|
||||
* 4. 使用 MPC 签名对摘要进行签名
|
||||
* 5. 返回完整的钱包信息
|
||||
*/
|
||||
async generateMpcWallet(
|
||||
params: MpcWalletGenerationParams,
|
||||
): Promise<MpcWalletGenerationResult> {
|
||||
this.logger.log(
|
||||
`Generating MPC wallet for user=${params.userId}, username=${params.username}`,
|
||||
);
|
||||
|
||||
// Step 1: 生成 MPC 密钥
|
||||
const keygenResult = await this.mpcClient.executeKeygen({
|
||||
sessionId: this.mpcClient.generateSessionId(),
|
||||
username: params.username,
|
||||
threshold: 1, // t in t-of-n (2-of-3 means t=1)
|
||||
totalParties: 3,
|
||||
requireDelegate: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`MPC keygen completed: publicKey=${keygenResult.publicKey}`,
|
||||
);
|
||||
|
||||
// Step 2: 从公钥派生三条链的地址
|
||||
const walletAddresses = await this.deriveChainAddresses(
|
||||
keygenResult.publicKey,
|
||||
);
|
||||
|
||||
// Step 3: 计算地址摘要
|
||||
const addressDigest = this.computeAddressDigest(walletAddresses);
|
||||
|
||||
// Step 4: 使用 MPC 签名对摘要进行签名
|
||||
const signingResult = await this.mpcClient.executeSigning({
|
||||
username: params.username,
|
||||
messageHash: addressDigest,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`MPC signing completed: signature=${signingResult.signature.slice(0, 16)}...`,
|
||||
);
|
||||
|
||||
// Step 5: 构建钱包信息
|
||||
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
||||
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
|
||||
address: wa.address,
|
||||
publicKey: keygenResult.publicKey,
|
||||
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
|
||||
signature: signingResult.signature,
|
||||
}));
|
||||
|
||||
return {
|
||||
publicKey: keygenResult.publicKey,
|
||||
delegateShare: keygenResult.delegateShare.encryptedShare,
|
||||
serverParties: keygenResult.serverParties,
|
||||
wallets,
|
||||
sessionId: keygenResult.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证钱包地址签名
|
||||
*
|
||||
* 用于检测地址是否被篡改
|
||||
*
|
||||
* @param address 钱包地址
|
||||
* @param chainType 链类型
|
||||
* @param publicKey 公钥 (hex)
|
||||
* @param signature 签名 (64 bytes hex: R + S)
|
||||
*/
|
||||
async verifyWalletSignature(
|
||||
address: string,
|
||||
chainType: string,
|
||||
publicKey: string,
|
||||
signature: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { ethers } = await import('ethers');
|
||||
|
||||
// 签名格式: R (32 bytes) + S (32 bytes) = 64 bytes hex
|
||||
if (signature.length !== 128) {
|
||||
this.logger.error(
|
||||
`Invalid signature length: ${signature.length}, expected 128`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = '0x' + signature.slice(0, 64);
|
||||
const s = '0x' + signature.slice(64, 128);
|
||||
|
||||
// 计算地址摘要
|
||||
const digest = this.computeSingleAddressDigest(address, chainType);
|
||||
const digestBytes = Buffer.from(digest, 'hex');
|
||||
|
||||
// 尝试两种 recovery id
|
||||
for (const v of [27, 28]) {
|
||||
try {
|
||||
const sig = ethers.Signature.from({ r, s, v });
|
||||
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(
|
||||
digestBytes,
|
||||
sig,
|
||||
);
|
||||
const compressedRecovered = ethers.SigningKey.computePublicKey(
|
||||
recoveredPubKey,
|
||||
true,
|
||||
);
|
||||
|
||||
if (
|
||||
compressedRecovered.slice(2).toLowerCase() ===
|
||||
publicKey.toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 尝试下一个 v 值
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.logger.error(`Signature verification failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MPC 公钥派生三条链的地址
|
||||
*
|
||||
* - BSC/KAVA: EVM 地址 (keccak256)
|
||||
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
||||
*/
|
||||
private async deriveChainAddresses(
|
||||
publicKey: string,
|
||||
): Promise<{ chainType: string; address: string }[]> {
|
||||
const { ethers } = await import('ethers');
|
||||
const { bech32 } = await import('bech32');
|
||||
|
||||
// MPC 公钥 (压缩格式,33 bytes)
|
||||
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
||||
const compressedPubKeyBytes = Buffer.from(
|
||||
pubKeyHex.replace('0x', ''),
|
||||
'hex',
|
||||
);
|
||||
|
||||
// 解压公钥 (如果是压缩格式)
|
||||
let uncompressedPubKey: string;
|
||||
if (pubKeyHex.length === 68) {
|
||||
// 压缩格式 (33 bytes = 66 hex chars + 0x)
|
||||
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
|
||||
} else {
|
||||
uncompressedPubKey = pubKeyHex;
|
||||
}
|
||||
|
||||
// ===== EVM 地址派生 (BSC, KAVA) =====
|
||||
// 地址 = keccak256(公钥[1:])[12:]
|
||||
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
|
||||
const addressHash = ethers.keccak256(pubKeyBytes);
|
||||
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
|
||||
|
||||
// ===== Cosmos 地址派生 (DST) =====
|
||||
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
||||
const sha256Hash = createHash('sha256')
|
||||
.update(compressedPubKeyBytes)
|
||||
.digest();
|
||||
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
||||
const dstAddress = bech32.encode(
|
||||
this.chainConfigs.DST.prefix,
|
||||
bech32.toWords(ripemd160Hash),
|
||||
);
|
||||
|
||||
return [
|
||||
{ chainType: 'BSC', address: evmAddress },
|
||||
{ chainType: 'KAVA', address: evmAddress },
|
||||
{ chainType: 'DST', address: dstAddress },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算三个地址的联合摘要
|
||||
*
|
||||
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
||||
*/
|
||||
private computeAddressDigest(
|
||||
addresses: { chainType: string; address: string }[],
|
||||
): string {
|
||||
// 按链类型排序以确保一致性
|
||||
const sortedAddresses = [...addresses].sort((a, b) =>
|
||||
a.chainType.localeCompare(b.chainType),
|
||||
);
|
||||
|
||||
// 拼接地址
|
||||
const concatenated = sortedAddresses
|
||||
.map((a) => a.address.toLowerCase())
|
||||
.join('');
|
||||
|
||||
// 计算 SHA256 摘要
|
||||
return createHash('sha256').update(concatenated).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单个地址的摘要
|
||||
*/
|
||||
private computeSingleAddressDigest(
|
||||
address: string,
|
||||
chainType: string,
|
||||
): string {
|
||||
const message = `${chainType}:${address.toLowerCase()}`;
|
||||
return createHash('sha256').update(message).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的链类型
|
||||
*/
|
||||
getSupportedChains(): string[] {
|
||||
return Object.keys(this.chainConfigs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { MpcWalletService } from './mpc-wallet.service';
|
||||
import { MpcClientService } from './mpc-client.service';
|
||||
import { KafkaModule } from '../../kafka/kafka.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 300000, // MPC 操作可能需要较长时间
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
KafkaModule, // 用于事件驱动模式
|
||||
],
|
||||
providers: [MpcWalletService, MpcClientService],
|
||||
exports: [MpcWalletService, MpcClientService],
|
||||
})
|
||||
export class MpcModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { MpcWalletService } from './mpc-wallet.service';
|
||||
import { MpcClientService } from './mpc-client.service';
|
||||
import { KafkaModule } from '../../kafka/kafka.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 300000, // MPC 操作可能需要较长时间
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
KafkaModule, // 用于事件驱动模式
|
||||
],
|
||||
providers: [MpcWalletService, MpcClientService],
|
||||
exports: [MpcWalletService, MpcClientService],
|
||||
})
|
||||
export class MpcModule {}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
|
|
|
|||
|
|
@ -1,256 +1,293 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
||||
import * as $OpenApi from '@alicloud/openapi-client';
|
||||
import * as $Util from '@alicloud/tea-util';
|
||||
|
||||
export interface SmsSendResult {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
bizId?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
private readonly signName: string;
|
||||
private readonly templateCode: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const smsConfig = this.configService.get('smsConfig') || {};
|
||||
const aliyunConfig = smsConfig.aliyun || {};
|
||||
|
||||
this.signName = aliyunConfig.signName || this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
||||
this.templateCode = aliyunConfig.templateCode || this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
||||
this.enabled = smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initClient();
|
||||
}
|
||||
|
||||
private async initClient(): Promise<void> {
|
||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
||||
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = new $OpenApi.Config({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
endpoint,
|
||||
});
|
||||
|
||||
this.client = new Dysmsapi20170525(config);
|
||||
this.logger.log('阿里云 SMS 客户端初始化成功');
|
||||
} catch (error) {
|
||||
this.logger.error('阿里云 SMS 客户端初始化失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码短信
|
||||
*
|
||||
* @param phoneNumber 手机号码(支持国际格式,如 +86xxx)
|
||||
* @param code 验证码
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
||||
// 标准化手机号(去除 +86 前缀)
|
||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
||||
|
||||
// 开发环境或未启用时,使用模拟模式
|
||||
if (!this.enabled || !this.client) {
|
||||
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
||||
return {
|
||||
success: true,
|
||||
requestId: 'mock-request-id',
|
||||
bizId: 'mock-biz-id',
|
||||
code: 'OK',
|
||||
message: '模拟发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalizedPhone,
|
||||
signName: this.signName,
|
||||
templateCode: this.templateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
||||
|
||||
const body = response.body;
|
||||
const result: SmsSendResult = {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
|
||||
} else {
|
||||
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
|
||||
|
||||
// 解析阿里云错误
|
||||
if (error.code) {
|
||||
return {
|
||||
success: false,
|
||||
code: error.code,
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通用短信
|
||||
*
|
||||
* @param phoneNumber 手机号码
|
||||
* @param templateCode 模板代码
|
||||
* @param templateParam 模板参数
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async sendSms(
|
||||
phoneNumber: string,
|
||||
templateCode: string,
|
||||
templateParam: Record<string, string>,
|
||||
): Promise<SmsSendResult> {
|
||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
if (!this.enabled || !this.client) {
|
||||
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
|
||||
return {
|
||||
success: true,
|
||||
requestId: 'mock-request-id',
|
||||
code: 'OK',
|
||||
message: '模拟发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalizedPhone,
|
||||
signName: this.signName,
|
||||
templateCode,
|
||||
templateParam: JSON.stringify(templateParam),
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
|
||||
|
||||
const body = response.body;
|
||||
return {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 发送异常: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
code: error.code || 'UNKNOWN_ERROR',
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询短信发送状态
|
||||
*
|
||||
* @param phoneNumber 手机号码
|
||||
* @param bizId 发送回执ID
|
||||
* @param sendDate 发送日期 (yyyyMMdd 格式)
|
||||
*/
|
||||
async querySendDetails(
|
||||
phoneNumber: string,
|
||||
bizId: string,
|
||||
sendDate: string,
|
||||
): Promise<any> {
|
||||
if (!this.client) {
|
||||
this.logger.warn('[SMS] 客户端未初始化,无法查询');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({
|
||||
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
||||
bizId,
|
||||
sendDate,
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime);
|
||||
|
||||
return response.body;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化手机号(去除国际区号前缀)
|
||||
*/
|
||||
private normalizePhoneNumber(phoneNumber: string): string {
|
||||
let normalized = phoneNumber.trim();
|
||||
|
||||
// 去除 +86 或 86 前缀
|
||||
if (normalized.startsWith('+86')) {
|
||||
normalized = normalized.substring(3);
|
||||
} else if (normalized.startsWith('86') && normalized.length === 13) {
|
||||
normalized = normalized.substring(2);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏手机号(用于日志)
|
||||
*/
|
||||
private maskPhoneNumber(phoneNumber: string): string {
|
||||
if (phoneNumber.length < 7) {
|
||||
return phoneNumber;
|
||||
}
|
||||
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4);
|
||||
}
|
||||
}
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
||||
import * as $OpenApi from '@alicloud/openapi-client';
|
||||
import * as $Util from '@alicloud/tea-util';
|
||||
|
||||
export interface SmsSendResult {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
bizId?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
private readonly signName: string;
|
||||
private readonly templateCode: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const smsConfig = this.configService.get('smsConfig') || {};
|
||||
const aliyunConfig = smsConfig.aliyun || {};
|
||||
|
||||
this.signName =
|
||||
aliyunConfig.signName ||
|
||||
this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
|
||||
this.templateCode =
|
||||
aliyunConfig.templateCode ||
|
||||
this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
||||
this.enabled =
|
||||
smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initClient();
|
||||
}
|
||||
|
||||
private async initClient(): Promise<void> {
|
||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||
const accessKeySecret = this.configService.get<string>(
|
||||
'ALIYUN_ACCESS_KEY_SECRET',
|
||||
);
|
||||
const endpoint = this.configService.get<string>(
|
||||
'ALIYUN_SMS_ENDPOINT',
|
||||
'dysmsapi.aliyuncs.com',
|
||||
);
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = new $OpenApi.Config({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
endpoint,
|
||||
});
|
||||
|
||||
this.client = new Dysmsapi20170525(config);
|
||||
this.logger.log('阿里云 SMS 客户端初始化成功');
|
||||
} catch (error) {
|
||||
this.logger.error('阿里云 SMS 客户端初始化失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码短信
|
||||
*
|
||||
* @param phoneNumber 手机号码(支持国际格式,如 +86xxx)
|
||||
* @param code 验证码
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async sendVerificationCode(
|
||||
phoneNumber: string,
|
||||
code: string,
|
||||
): Promise<SmsSendResult> {
|
||||
// 标准化手机号(去除 +86 前缀)
|
||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
this.logger.log(
|
||||
`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||
);
|
||||
|
||||
// 开发环境或未启用时,使用模拟模式
|
||||
if (!this.enabled || !this.client) {
|
||||
this.logger.warn(
|
||||
`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
requestId: 'mock-request-id',
|
||||
bizId: 'mock-biz-id',
|
||||
code: 'OK',
|
||||
message: '模拟发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalizedPhone,
|
||||
signName: this.signName,
|
||||
templateCode: this.templateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.sendSmsWithOptions(
|
||||
sendSmsRequest,
|
||||
runtime,
|
||||
);
|
||||
|
||||
const body = response.body;
|
||||
const result: SmsSendResult = {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log(
|
||||
`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[SMS] 发送失败: code=${result.code}, message=${result.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
|
||||
|
||||
// 解析阿里云错误
|
||||
if (error.code) {
|
||||
return {
|
||||
success: false,
|
||||
code: error.code,
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通用短信
|
||||
*
|
||||
* @param phoneNumber 手机号码
|
||||
* @param templateCode 模板代码
|
||||
* @param templateParam 模板参数
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async sendSms(
|
||||
phoneNumber: string,
|
||||
templateCode: string,
|
||||
templateParam: Record<string, string>,
|
||||
): Promise<SmsSendResult> {
|
||||
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
if (!this.enabled || !this.client) {
|
||||
this.logger.warn(
|
||||
`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
requestId: 'mock-request-id',
|
||||
code: 'OK',
|
||||
message: '模拟发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalizedPhone,
|
||||
signName: this.signName,
|
||||
templateCode,
|
||||
templateParam: JSON.stringify(templateParam),
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.sendSmsWithOptions(
|
||||
sendSmsRequest,
|
||||
runtime,
|
||||
);
|
||||
|
||||
const body = response.body;
|
||||
return {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 发送异常: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
code: error.code || 'UNKNOWN_ERROR',
|
||||
message: error.message || '短信发送失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询短信发送状态
|
||||
*
|
||||
* @param phoneNumber 手机号码
|
||||
* @param bizId 发送回执ID
|
||||
* @param sendDate 发送日期 (yyyyMMdd 格式)
|
||||
*/
|
||||
async querySendDetails(
|
||||
phoneNumber: string,
|
||||
bizId: string,
|
||||
sendDate: string,
|
||||
): Promise<any> {
|
||||
if (!this.client) {
|
||||
this.logger.warn('[SMS] 客户端未初始化,无法查询');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const querySendDetailsRequest =
|
||||
new $Dysmsapi20170525.QuerySendDetailsRequest({
|
||||
phoneNumber: this.normalizePhoneNumber(phoneNumber),
|
||||
bizId,
|
||||
sendDate,
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
});
|
||||
|
||||
const runtime = new $Util.RuntimeOptions({
|
||||
connectTimeout: 10000, // 连接超时 10 秒
|
||||
readTimeout: 10000, // 读取超时 10 秒
|
||||
});
|
||||
const response = await this.client.querySendDetailsWithOptions(
|
||||
querySendDetailsRequest,
|
||||
runtime,
|
||||
);
|
||||
|
||||
return response.body;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化手机号(去除国际区号前缀)
|
||||
*/
|
||||
private normalizePhoneNumber(phoneNumber: string): string {
|
||||
let normalized = phoneNumber.trim();
|
||||
|
||||
// 去除 +86 或 86 前缀
|
||||
if (normalized.startsWith('+86')) {
|
||||
normalized = normalized.substring(3);
|
||||
} else if (normalized.startsWith('86') && normalized.length === 13) {
|
||||
normalized = normalized.substring(2);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏手机号(用于日志)
|
||||
*/
|
||||
private maskPhoneNumber(phoneNumber: string): string {
|
||||
if (phoneNumber.length < 7) {
|
||||
return phoneNumber;
|
||||
}
|
||||
return (
|
||||
phoneNumber.substring(0, 3) +
|
||||
'****' +
|
||||
phoneNumber.substring(phoneNumber.length - 4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,14 +30,30 @@ export class StorageService implements OnModuleInit {
|
|||
private publicUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost');
|
||||
const endpoint = this.configService.get<string>(
|
||||
'MINIO_ENDPOINT',
|
||||
'localhost',
|
||||
);
|
||||
const port = this.configService.get<number>('MINIO_PORT', 9000);
|
||||
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
|
||||
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
|
||||
const useSSL =
|
||||
this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||
const accessKey = this.configService.get<string>(
|
||||
'MINIO_ACCESS_KEY',
|
||||
'admin',
|
||||
);
|
||||
const secretKey = this.configService.get<string>(
|
||||
'MINIO_SECRET_KEY',
|
||||
'minio_secret_password',
|
||||
);
|
||||
|
||||
this.bucketAvatars = this.configService.get<string>('MINIO_BUCKET_AVATARS', 'avatars');
|
||||
this.publicUrl = this.configService.get<string>('MINIO_PUBLIC_URL', 'http://localhost:9000');
|
||||
this.bucketAvatars = this.configService.get<string>(
|
||||
'MINIO_BUCKET_AVATARS',
|
||||
'avatars',
|
||||
);
|
||||
this.publicUrl = this.configService.get<string>(
|
||||
'MINIO_PUBLIC_URL',
|
||||
'http://localhost:9000',
|
||||
);
|
||||
|
||||
this.client = new Minio.Client({
|
||||
endPoint: endpoint,
|
||||
|
|
@ -83,7 +99,9 @@ export class StorageService implements OnModuleInit {
|
|||
this.logger.log(`Bucket exists: ${bucketName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to ensure bucket ${bucketName}: ${error.message}`);
|
||||
this.logger.error(
|
||||
`Failed to ensure bucket ${bucketName}: ${error.message}`,
|
||||
);
|
||||
// 不抛出异常,允许服务启动(MinIO可能暂时不可用)
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +174,7 @@ export class StorageService implements OnModuleInit {
|
|||
try {
|
||||
const urlObj = new URL(url);
|
||||
// URL格式: http://host/bucket/key
|
||||
const pathParts = urlObj.pathname.split('/').filter(p => p);
|
||||
const pathParts = urlObj.pathname.split('/').filter((p) => p);
|
||||
if (pathParts.length >= 2 && pathParts[0] === this.bucketAvatars) {
|
||||
return pathParts.slice(1).join('/');
|
||||
}
|
||||
|
|
@ -184,7 +202,13 @@ export class StorageService implements OnModuleInit {
|
|||
* 验证图片类型
|
||||
*/
|
||||
isValidImageType(contentType: string): boolean {
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const validTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
return validTypes.includes(contentType);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +1,65 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
||||
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
|
||||
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
||||
import { RedisService } from './redis/redis.service';
|
||||
import { EventPublisherService } from './kafka/event-publisher.service';
|
||||
import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
||||
import { SmsService } from './external/sms/sms.service';
|
||||
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||
import { StorageService } from './external/storage/storage.service';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 300000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
UserAccountRepositoryImpl,
|
||||
{
|
||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||
useClass: MpcKeyShareRepositoryImpl,
|
||||
},
|
||||
UserAccountMapper,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
// BlockchainClientService 调用 blockchain-service API
|
||||
BlockchainClientService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
StorageService,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
UserAccountRepositoryImpl,
|
||||
{
|
||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||
useClass: MpcKeyShareRepositoryImpl,
|
||||
},
|
||||
UserAccountMapper,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
BlockchainClientService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
StorageService,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
||||
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
|
||||
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
||||
import { RedisService } from './redis/redis.service';
|
||||
import { EventPublisherService } from './kafka/event-publisher.service';
|
||||
import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
||||
import { SmsService } from './external/sms/sms.service';
|
||||
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||
import { StorageService } from './external/storage/storage.service';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 300000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
UserAccountRepositoryImpl,
|
||||
{
|
||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||
useClass: MpcKeyShareRepositoryImpl,
|
||||
},
|
||||
UserAccountMapper,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
// BlockchainClientService 调用 blockchain-service API
|
||||
BlockchainClientService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
StorageService,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
UserAccountRepositoryImpl,
|
||||
{
|
||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||
useClass: MpcKeyShareRepositoryImpl,
|
||||
},
|
||||
UserAccountMapper,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
SmsService,
|
||||
BlockchainClientService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
StorageService,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@
|
|||
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||
|
||||
|
|
@ -22,15 +27,17 @@ export interface WalletAddressCreatedPayload {
|
|||
address: string;
|
||||
}[];
|
||||
// 恢复助记词相关
|
||||
mnemonic?: string; // 12词助记词 (明文)
|
||||
encryptedMnemonic?: string; // 加密的助记词
|
||||
mnemonicHash?: string; // 助记词哈希
|
||||
mnemonic?: string; // 12词助记词 (明文)
|
||||
encryptedMnemonic?: string; // 加密的助记词
|
||||
mnemonicHash?: string; // 助记词哈希
|
||||
}
|
||||
|
||||
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
||||
|
||||
@Injectable()
|
||||
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||
export class BlockchainEventConsumerService
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(BlockchainEventConsumerService.name);
|
||||
private kafka: Kafka;
|
||||
private consumer: Consumer;
|
||||
|
|
@ -41,15 +48,20 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
|||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||
const brokers = this.configService
|
||||
.get<string>('KAFKA_BROKERS')
|
||||
?.split(',') || ['localhost:9092'];
|
||||
const clientId =
|
||||
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||
const groupId = 'identity-service-blockchain-events';
|
||||
|
||||
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
|
||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
||||
this.logger.log(
|
||||
`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||
);
|
||||
|
||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||
this.kafka = new Kafka({
|
||||
|
|
@ -57,10 +69,10 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
|||
brokers,
|
||||
logLevel: logLevel.WARN,
|
||||
retry: {
|
||||
initialRetryTime: 1000, // 1 秒
|
||||
maxRetryTime: 300000, // 最大 5 分钟
|
||||
retries: 15, // 最多 15 次
|
||||
multiplier: 2, // 指数退避因子
|
||||
initialRetryTime: 1000, // 1 秒
|
||||
maxRetryTime: 300000, // 最大 5 分钟
|
||||
retries: 15, // 最多 15 次
|
||||
multiplier: 2, // 指数退避因子
|
||||
restartOnFailure: async () => true,
|
||||
},
|
||||
});
|
||||
|
|
@ -75,16 +87,26 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
|||
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
this.logger.log(`[CONNECT] Blockchain Event Kafka consumer connected successfully`);
|
||||
this.logger.log(
|
||||
`[CONNECT] Blockchain Event Kafka consumer connected successfully`,
|
||||
);
|
||||
|
||||
// Subscribe to blockchain topics
|
||||
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false });
|
||||
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
|
||||
await this.consumer.subscribe({
|
||||
topics: Object.values(BLOCKCHAIN_TOPICS),
|
||||
fromBeginning: false,
|
||||
});
|
||||
this.logger.log(
|
||||
`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`,
|
||||
);
|
||||
|
||||
// Start consuming
|
||||
await this.startConsuming();
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to connect Blockchain Event Kafka consumer`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to connect Blockchain Event Kafka consumer`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,16 +120,24 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
|||
/**
|
||||
* Register handler for wallet address created events
|
||||
*/
|
||||
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void {
|
||||
onWalletAddressCreated(
|
||||
handler: BlockchainEventHandler<WalletAddressCreatedPayload>,
|
||||
): void {
|
||||
this.walletAddressCreatedHandler = handler;
|
||||
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
|
||||
}
|
||||
|
||||
private async startConsuming(): Promise<void> {
|
||||
await this.consumer.run({
|
||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
||||
eachMessage: async ({
|
||||
topic,
|
||||
partition,
|
||||
message,
|
||||
}: EachMessagePayload) => {
|
||||
const offset = message.offset;
|
||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const value = message.value?.toString();
|
||||
|
|
@ -116,33 +146,53 @@ export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDes
|
|||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
const payload = parsed.payload || parsed;
|
||||
const eventType = parsed.eventType || 'unknown';
|
||||
|
||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
|
||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||
);
|
||||
|
||||
// Handle WalletAddressCreated events
|
||||
if (eventType === 'blockchain.wallet.address.created' || topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED) {
|
||||
if (
|
||||
eventType === 'blockchain.wallet.address.created' ||
|
||||
topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED
|
||||
) {
|
||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
|
||||
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
|
||||
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
|
||||
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`);
|
||||
this.logger.log(
|
||||
`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[HANDLE] addresses count: ${payload.addresses?.length}`,
|
||||
);
|
||||
|
||||
if (this.walletAddressCreatedHandler) {
|
||||
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload);
|
||||
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`);
|
||||
await this.walletAddressCreatedHandler(
|
||||
payload as WalletAddressCreatedPayload,
|
||||
);
|
||||
this.logger.log(
|
||||
`[HANDLE] WalletAddressCreated handler completed successfully`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`);
|
||||
this.logger.warn(
|
||||
`[HANDLE] No handler registered for WalletAddressCreated`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Error processing blockchain event from ${topic}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Error processing blockchain event from ${topic}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
// This ensures messages are not marked as consumed until successfully processed
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,83 +1,85 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
import { DomainEventMessage } from './event-publisher.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeadLetterService {
|
||||
private readonly logger = new Logger(DeadLetterService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async saveFailedEvent(
|
||||
topic: string,
|
||||
message: DomainEventMessage,
|
||||
error: Error,
|
||||
retryCount: number,
|
||||
): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.create({
|
||||
data: {
|
||||
topic,
|
||||
eventId: message.eventId,
|
||||
eventType: message.eventType,
|
||||
aggregateId: message.aggregateId,
|
||||
aggregateType: message.aggregateType,
|
||||
payload: message.payload,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
retryCount,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
|
||||
}
|
||||
|
||||
async getFailedEvents(limit: number = 100): Promise<any[]> {
|
||||
return this.prisma.deadLetterEvent.findMany({
|
||||
where: { processedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async markAsProcessed(id: bigint): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.update({
|
||||
where: { id },
|
||||
data: { processedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.log(`Dead letter event marked as processed: ${id}`);
|
||||
}
|
||||
|
||||
async incrementRetryCount(id: bigint): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.update({
|
||||
where: { id },
|
||||
data: { retryCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async getStatistics(): Promise<{
|
||||
total: number;
|
||||
pending: number;
|
||||
processed: number;
|
||||
byTopic: Record<string, number>;
|
||||
}> {
|
||||
const [total, pending, processed, byTopic] = await Promise.all([
|
||||
this.prisma.deadLetterEvent.count(),
|
||||
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
||||
this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }),
|
||||
this.prisma.deadLetterEvent.groupBy({
|
||||
by: ['topic'],
|
||||
_count: true,
|
||||
where: { processedAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
const topicStats: Record<string, number> = {};
|
||||
for (const item of byTopic) {
|
||||
topicStats[item.topic] = item._count;
|
||||
}
|
||||
|
||||
return { total, pending, processed, byTopic: topicStats };
|
||||
}
|
||||
}
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
import { DomainEventMessage } from './event-publisher.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeadLetterService {
|
||||
private readonly logger = new Logger(DeadLetterService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async saveFailedEvent(
|
||||
topic: string,
|
||||
message: DomainEventMessage,
|
||||
error: Error,
|
||||
retryCount: number,
|
||||
): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.create({
|
||||
data: {
|
||||
topic,
|
||||
eventId: message.eventId,
|
||||
eventType: message.eventType,
|
||||
aggregateId: message.aggregateId,
|
||||
aggregateType: message.aggregateType,
|
||||
payload: message.payload,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
retryCount,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
|
||||
}
|
||||
|
||||
async getFailedEvents(limit: number = 100): Promise<any[]> {
|
||||
return this.prisma.deadLetterEvent.findMany({
|
||||
where: { processedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async markAsProcessed(id: bigint): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.update({
|
||||
where: { id },
|
||||
data: { processedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.log(`Dead letter event marked as processed: ${id}`);
|
||||
}
|
||||
|
||||
async incrementRetryCount(id: bigint): Promise<void> {
|
||||
await this.prisma.deadLetterEvent.update({
|
||||
where: { id },
|
||||
data: { retryCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async getStatistics(): Promise<{
|
||||
total: number;
|
||||
pending: number;
|
||||
processed: number;
|
||||
byTopic: Record<string, number>;
|
||||
}> {
|
||||
const [total, pending, processed, byTopic] = await Promise.all([
|
||||
this.prisma.deadLetterEvent.count(),
|
||||
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
|
||||
this.prisma.deadLetterEvent.count({
|
||||
where: { processedAt: { not: null } },
|
||||
}),
|
||||
this.prisma.deadLetterEvent.groupBy({
|
||||
by: ['topic'],
|
||||
_count: true,
|
||||
where: { processedAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
const topicStats: Record<string, number> = {};
|
||||
for (const item of byTopic) {
|
||||
topicStats[item.topic] = item._count;
|
||||
}
|
||||
|
||||
return { total, pending, processed, byTopic: topicStats };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,237 +1,239 @@
|
|||
import { Controller, Logger } from '@nestjs/common';
|
||||
import {
|
||||
MessagePattern,
|
||||
Payload,
|
||||
Ctx,
|
||||
KafkaContext,
|
||||
} from '@nestjs/microservices';
|
||||
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
|
||||
|
||||
@Controller()
|
||||
export class EventConsumerController {
|
||||
private readonly logger = new Logger(EventConsumerController.name);
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
|
||||
async handleUserAccountCreated(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processUserAccountCreated(message.payload);
|
||||
this.logger.log(
|
||||
`Successfully processed UserAccountCreated: ${message.eventId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process UserAccountCreated: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
|
||||
async handleDeviceAdded(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processDeviceAdded(message.payload);
|
||||
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process DeviceAdded: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
|
||||
async handlePhoneBound(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processPhoneBound(message.payload);
|
||||
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process PhoneBound: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
|
||||
async handleKYCSubmitted(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCSubmitted(message.payload);
|
||||
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCSubmitted: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
|
||||
async handleKYCApproved(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCApproved event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCApproved(message.payload);
|
||||
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCApproved: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
|
||||
async handleKYCRejected(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCRejected event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCRejected(message.payload);
|
||||
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCRejected: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
|
||||
async handleAccountFrozen(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processAccountFrozen(message.payload);
|
||||
this.logger.log(
|
||||
`Successfully processed AccountFrozen: ${message.eventId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process AccountFrozen: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
|
||||
async handleWalletBound(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received WalletBound event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processWalletBound(message.payload);
|
||||
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process WalletBound: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 业务处理方法
|
||||
private async processUserAccountCreated(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing UserAccountCreated: userId=${payload.userId}`,
|
||||
);
|
||||
// 发送欢迎通知
|
||||
// 初始化用户积分
|
||||
// 记录邀请关系
|
||||
}
|
||||
|
||||
private async processDeviceAdded(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
|
||||
);
|
||||
// 发送新设备登录通知
|
||||
// 安全审计记录
|
||||
}
|
||||
|
||||
private async processPhoneBound(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
|
||||
// 发送绑定成功短信
|
||||
}
|
||||
|
||||
private async processKYCSubmitted(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
|
||||
// 触发KYC审核流程
|
||||
// 通知审核人员
|
||||
}
|
||||
|
||||
private async processKYCApproved(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
|
||||
// 发送审核通过通知
|
||||
// 解锁高级功能
|
||||
}
|
||||
|
||||
private async processKYCRejected(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
|
||||
// 发送审核失败通知
|
||||
}
|
||||
|
||||
private async processAccountFrozen(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
|
||||
// 发送账户冻结通知
|
||||
// 清除用户会话
|
||||
}
|
||||
|
||||
private async processWalletBound(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
|
||||
);
|
||||
// 同步钱包余额
|
||||
}
|
||||
}
|
||||
import { Controller, Logger } from '@nestjs/common';
|
||||
import {
|
||||
MessagePattern,
|
||||
Payload,
|
||||
Ctx,
|
||||
KafkaContext,
|
||||
} from '@nestjs/microservices';
|
||||
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
|
||||
|
||||
@Controller()
|
||||
export class EventConsumerController {
|
||||
private readonly logger = new Logger(EventConsumerController.name);
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
|
||||
async handleUserAccountCreated(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processUserAccountCreated(message.payload);
|
||||
this.logger.log(
|
||||
`Successfully processed UserAccountCreated: ${message.eventId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process UserAccountCreated: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
|
||||
async handleDeviceAdded(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processDeviceAdded(message.payload);
|
||||
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process DeviceAdded: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
|
||||
async handlePhoneBound(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
const { offset } = context.getMessage();
|
||||
const partition = context.getPartition();
|
||||
|
||||
this.logger.log(
|
||||
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.processPhoneBound(message.payload);
|
||||
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process PhoneBound: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
|
||||
async handleKYCSubmitted(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCSubmitted(message.payload);
|
||||
this.logger.log(
|
||||
`Successfully processed KYCSubmitted: ${message.eventId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCSubmitted: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
|
||||
async handleKYCApproved(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCApproved event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCApproved(message.payload);
|
||||
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCApproved: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
|
||||
async handleKYCRejected(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received KYCRejected event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processKYCRejected(message.payload);
|
||||
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process KYCRejected: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
|
||||
async handleAccountFrozen(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processAccountFrozen(message.payload);
|
||||
this.logger.log(
|
||||
`Successfully processed AccountFrozen: ${message.eventId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process AccountFrozen: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
|
||||
async handleWalletBound(
|
||||
@Payload() message: DomainEventMessage,
|
||||
@Ctx() context: KafkaContext,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received WalletBound event: ${message.eventId}`);
|
||||
|
||||
try {
|
||||
await this.processWalletBound(message.payload);
|
||||
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process WalletBound: ${message.eventId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 业务处理方法
|
||||
private async processUserAccountCreated(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing UserAccountCreated: userId=${payload.userId}`,
|
||||
);
|
||||
// 发送欢迎通知
|
||||
// 初始化用户积分
|
||||
// 记录邀请关系
|
||||
}
|
||||
|
||||
private async processDeviceAdded(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
|
||||
);
|
||||
// 发送新设备登录通知
|
||||
// 安全审计记录
|
||||
}
|
||||
|
||||
private async processPhoneBound(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
|
||||
// 发送绑定成功短信
|
||||
}
|
||||
|
||||
private async processKYCSubmitted(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
|
||||
// 触发KYC审核流程
|
||||
// 通知审核人员
|
||||
}
|
||||
|
||||
private async processKYCApproved(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
|
||||
// 发送审核通过通知
|
||||
// 解锁高级功能
|
||||
}
|
||||
|
||||
private async processKYCRejected(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
|
||||
// 发送审核失败通知
|
||||
}
|
||||
|
||||
private async processAccountFrozen(payload: any): Promise<void> {
|
||||
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
|
||||
// 发送账户冻结通知
|
||||
// 清除用户会话
|
||||
}
|
||||
|
||||
private async processWalletBound(payload: any): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
|
||||
);
|
||||
// 同步钱包余额
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,157 +1,176 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||
import { DomainEvent } from '@/domain/events';
|
||||
|
||||
// 定义 Kafka 消息接口
|
||||
export interface DomainEventMessage {
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
aggregateId: string;
|
||||
aggregateType: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
// 定义主题常量 - identity-service 发布的事件
|
||||
export const IDENTITY_TOPICS = {
|
||||
USER_ACCOUNT_CREATED: 'identity.UserAccountCreated',
|
||||
USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated',
|
||||
DEVICE_ADDED: 'identity.DeviceAdded',
|
||||
DEVICE_REMOVED: 'identity.DeviceRemoved',
|
||||
PHONE_BOUND: 'identity.PhoneBound',
|
||||
WALLET_BOUND: 'identity.WalletBound',
|
||||
MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound',
|
||||
KYC_SUBMITTED: 'identity.KYCSubmitted',
|
||||
KYC_VERIFIED: 'identity.KYCVerified',
|
||||
KYC_REJECTED: 'identity.KYCRejected',
|
||||
KYC_APPROVED: 'identity.KYCApproved',
|
||||
USER_LOCATION_UPDATED: 'identity.UserLocationUpdated',
|
||||
USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen',
|
||||
ACCOUNT_FROZEN: 'identity.AccountFrozen',
|
||||
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated',
|
||||
// MPC 请求发送到 mpc.* topic,让 mpc-service 消费
|
||||
MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested',
|
||||
MPC_SIGNING_REQUESTED: 'mpc.SigningRequested',
|
||||
} as const;
|
||||
|
||||
// 定义 identity-service 需要消费的 MPC 事件主题
|
||||
export const MPC_CONSUME_TOPICS = {
|
||||
KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
|
||||
SESSION_FAILED: 'mpc.SessionFailed',
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventPublisherService.name);
|
||||
private kafka: Kafka;
|
||||
private producer: Producer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service');
|
||||
|
||||
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||
|
||||
this.kafka = new Kafka({
|
||||
clientId,
|
||||
brokers,
|
||||
logLevel: logLevel.WARN,
|
||||
});
|
||||
this.producer = this.kafka.producer();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
|
||||
await this.producer.connect();
|
||||
this.logger.log(`[CONNECT] Kafka producer connected successfully`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`);
|
||||
await this.producer.disconnect();
|
||||
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
|
||||
}
|
||||
|
||||
async publish(event: DomainEvent): Promise<void>;
|
||||
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
||||
async publish(eventOrTopic: DomainEvent | string, message?: DomainEventMessage): Promise<void> {
|
||||
if (typeof eventOrTopic === 'string') {
|
||||
// 直接发布到指定 topic (用于重试场景)
|
||||
const topic = eventOrTopic;
|
||||
const msg = message!;
|
||||
|
||||
this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`);
|
||||
this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
|
||||
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
key: msg.eventId,
|
||||
value: JSON.stringify(msg),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.log(`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`);
|
||||
} else {
|
||||
// 从领域事件发布
|
||||
const event = eventOrTopic;
|
||||
const topic = this.getTopicForEvent(event);
|
||||
const payload = (event as any).payload;
|
||||
|
||||
this.logger.log(`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`);
|
||||
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
||||
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
||||
|
||||
const messageValue = {
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
aggregateId: (event as any).aggregateId || '',
|
||||
aggregateType: (event as any).aggregateType || 'UserAccount',
|
||||
payload,
|
||||
};
|
||||
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
key: event.eventId,
|
||||
value: JSON.stringify(messageValue),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.log(`[PUBLISH] Successfully published ${event.eventType} to ${topic}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据事件类型获取对应的 Kafka topic
|
||||
* MPC 相关事件发送到 mpc.* topic,其他事件发送到 identity.* topic
|
||||
*/
|
||||
private getTopicForEvent(event: DomainEvent): string {
|
||||
const eventType = event.eventType;
|
||||
|
||||
// MPC 相关事件使用 mpc.* 前缀
|
||||
if (eventType === 'MpcKeygenRequested') {
|
||||
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED;
|
||||
}
|
||||
if (eventType === 'MpcSigningRequested') {
|
||||
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
|
||||
}
|
||||
|
||||
// 其他事件使用 identity.* 前缀
|
||||
return `identity.${eventType}`;
|
||||
}
|
||||
|
||||
async publishAll(events: DomainEvent[]): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.publish(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||
import { DomainEvent } from '@/domain/events';
|
||||
|
||||
// 定义 Kafka 消息接口
|
||||
export interface DomainEventMessage {
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
aggregateId: string;
|
||||
aggregateType: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
// 定义主题常量 - identity-service 发布的事件
|
||||
export const IDENTITY_TOPICS = {
|
||||
USER_ACCOUNT_CREATED: 'identity.UserAccountCreated',
|
||||
USER_ACCOUNT_AUTO_CREATED: 'identity.UserAccountAutoCreated',
|
||||
DEVICE_ADDED: 'identity.DeviceAdded',
|
||||
DEVICE_REMOVED: 'identity.DeviceRemoved',
|
||||
PHONE_BOUND: 'identity.PhoneBound',
|
||||
WALLET_BOUND: 'identity.WalletBound',
|
||||
MULTIPLE_WALLETS_BOUND: 'identity.MultipleWalletsBound',
|
||||
KYC_SUBMITTED: 'identity.KYCSubmitted',
|
||||
KYC_VERIFIED: 'identity.KYCVerified',
|
||||
KYC_REJECTED: 'identity.KYCRejected',
|
||||
KYC_APPROVED: 'identity.KYCApproved',
|
||||
USER_LOCATION_UPDATED: 'identity.UserLocationUpdated',
|
||||
USER_ACCOUNT_FROZEN: 'identity.UserAccountFrozen',
|
||||
ACCOUNT_FROZEN: 'identity.AccountFrozen',
|
||||
USER_ACCOUNT_DEACTIVATED: 'identity.UserAccountDeactivated',
|
||||
// MPC 请求发送到 mpc.* topic,让 mpc-service 消费
|
||||
MPC_KEYGEN_REQUESTED: 'mpc.KeygenRequested',
|
||||
MPC_SIGNING_REQUESTED: 'mpc.SigningRequested',
|
||||
} as const;
|
||||
|
||||
// 定义 identity-service 需要消费的 MPC 事件主题
|
||||
export const MPC_CONSUME_TOPICS = {
|
||||
KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
|
||||
SESSION_FAILED: 'mpc.SessionFailed',
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventPublisherService.name);
|
||||
private kafka: Kafka;
|
||||
private producer: Producer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const brokers = this.configService
|
||||
.get<string>('KAFKA_BROKERS', 'localhost:9092')
|
||||
.split(',');
|
||||
const clientId = this.configService.get<string>(
|
||||
'KAFKA_CLIENT_ID',
|
||||
'identity-service',
|
||||
);
|
||||
|
||||
this.logger.log(`[INIT] Kafka EventPublisher initializing...`);
|
||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||
|
||||
this.kafka = new Kafka({
|
||||
clientId,
|
||||
brokers,
|
||||
logLevel: logLevel.WARN,
|
||||
});
|
||||
this.producer = this.kafka.producer();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log(`[CONNECT] Connecting Kafka producer...`);
|
||||
await this.producer.connect();
|
||||
this.logger.log(`[CONNECT] Kafka producer connected successfully`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log(`[DISCONNECT] Disconnecting Kafka producer...`);
|
||||
await this.producer.disconnect();
|
||||
this.logger.log(`[DISCONNECT] Kafka producer disconnected`);
|
||||
}
|
||||
|
||||
async publish(event: DomainEvent): Promise<void>;
|
||||
async publish(topic: string, message: DomainEventMessage): Promise<void>;
|
||||
async publish(
|
||||
eventOrTopic: DomainEvent | string,
|
||||
message?: DomainEventMessage,
|
||||
): Promise<void> {
|
||||
if (typeof eventOrTopic === 'string') {
|
||||
// 直接发布到指定 topic (用于重试场景)
|
||||
const topic = eventOrTopic;
|
||||
const msg = message!;
|
||||
|
||||
this.logger.log(`[PUBLISH] Publishing to topic: ${topic}`);
|
||||
this.logger.debug(`[PUBLISH] Message: ${JSON.stringify(msg)}`);
|
||||
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
key: msg.eventId,
|
||||
value: JSON.stringify(msg),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[PUBLISH] Successfully published eventId=${msg.eventId} to ${topic}`,
|
||||
);
|
||||
} else {
|
||||
// 从领域事件发布
|
||||
const event = eventOrTopic;
|
||||
const topic = this.getTopicForEvent(event);
|
||||
const payload = (event as any).payload;
|
||||
|
||||
this.logger.log(
|
||||
`[PUBLISH] Publishing event: type=${event.eventType}, topic=${topic}`,
|
||||
);
|
||||
this.logger.log(`[PUBLISH] EventId: ${event.eventId}`);
|
||||
this.logger.debug(`[PUBLISH] Payload: ${JSON.stringify(payload)}`);
|
||||
|
||||
const messageValue = {
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
aggregateId: (event as any).aggregateId || '',
|
||||
aggregateType: (event as any).aggregateType || 'UserAccount',
|
||||
payload,
|
||||
};
|
||||
|
||||
await this.producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
key: event.eventId,
|
||||
value: JSON.stringify(messageValue),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[PUBLISH] Successfully published ${event.eventType} to ${topic}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据事件类型获取对应的 Kafka topic
|
||||
* MPC 相关事件发送到 mpc.* topic,其他事件发送到 identity.* topic
|
||||
*/
|
||||
private getTopicForEvent(event: DomainEvent): string {
|
||||
const eventType = event.eventType;
|
||||
|
||||
// MPC 相关事件使用 mpc.* 前缀
|
||||
if (eventType === 'MpcKeygenRequested') {
|
||||
return IDENTITY_TOPICS.MPC_KEYGEN_REQUESTED;
|
||||
}
|
||||
if (eventType === 'MpcSigningRequested') {
|
||||
return IDENTITY_TOPICS.MPC_SIGNING_REQUESTED;
|
||||
}
|
||||
|
||||
// 其他事件使用 identity.* 前缀
|
||||
return `identity.${eventType}`;
|
||||
}
|
||||
|
||||
async publishAll(events: DomainEvent[]): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.publish(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,94 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventPublisherService } from './event-publisher.service';
|
||||
import { DeadLetterService } from './dead-letter.service';
|
||||
|
||||
@Injectable()
|
||||
export class EventRetryService {
|
||||
private readonly logger = new Logger(EventRetryService.name);
|
||||
private readonly maxRetries = 3;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly deadLetterService: DeadLetterService,
|
||||
) {}
|
||||
|
||||
// 可以通过 API 手动触发或由外部调度器调用
|
||||
async retryFailedEvents(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.debug('Retry job already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.logger.log('Starting failed events retry job');
|
||||
|
||||
try {
|
||||
const failedEvents = await this.deadLetterService.getFailedEvents(50);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const event of failedEvents) {
|
||||
if (event.retryCount >= this.maxRetries) {
|
||||
this.logger.warn(
|
||||
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.eventPublisher.publish(event.topic, {
|
||||
eventId: event.eventId,
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
aggregateId: event.aggregateId,
|
||||
aggregateType: event.aggregateType,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
});
|
||||
|
||||
await this.deadLetterService.markAsProcessed(event.id);
|
||||
successCount++;
|
||||
this.logger.log(`Successfully retried event: ${event.eventId}`);
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
await this.deadLetterService.incrementRetryCount(event.id);
|
||||
this.logger.error(`Failed to retry event: ${event.eventId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished retry job: ${successCount} succeeded, ${failCount} failed`,
|
||||
);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async manualRetry(eventId: string): Promise<boolean> {
|
||||
const events = await this.deadLetterService.getFailedEvents(1000);
|
||||
const event = events.find((e) => e.eventId === eventId);
|
||||
|
||||
if (!event) {
|
||||
this.logger.warn(`Event not found: ${eventId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.eventPublisher.publish(event.topic, {
|
||||
eventId: event.eventId,
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
aggregateId: event.aggregateId,
|
||||
aggregateType: event.aggregateType,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
});
|
||||
|
||||
await this.deadLetterService.markAsProcessed(event.id);
|
||||
this.logger.log(`Manually retried event: ${eventId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to manually retry event: ${eventId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventPublisherService } from './event-publisher.service';
|
||||
import { DeadLetterService } from './dead-letter.service';
|
||||
|
||||
@Injectable()
|
||||
export class EventRetryService {
|
||||
private readonly logger = new Logger(EventRetryService.name);
|
||||
private readonly maxRetries = 3;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly deadLetterService: DeadLetterService,
|
||||
) {}
|
||||
|
||||
// 可以通过 API 手动触发或由外部调度器调用
|
||||
async retryFailedEvents(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.debug('Retry job already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.logger.log('Starting failed events retry job');
|
||||
|
||||
try {
|
||||
const failedEvents = await this.deadLetterService.getFailedEvents(50);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const event of failedEvents) {
|
||||
if (event.retryCount >= this.maxRetries) {
|
||||
this.logger.warn(
|
||||
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.eventPublisher.publish(event.topic, {
|
||||
eventId: event.eventId,
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
aggregateId: event.aggregateId,
|
||||
aggregateType: event.aggregateType,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
});
|
||||
|
||||
await this.deadLetterService.markAsProcessed(event.id);
|
||||
successCount++;
|
||||
this.logger.log(`Successfully retried event: ${event.eventId}`);
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
await this.deadLetterService.incrementRetryCount(event.id);
|
||||
this.logger.error(`Failed to retry event: ${event.eventId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished retry job: ${successCount} succeeded, ${failCount} failed`,
|
||||
);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async manualRetry(eventId: string): Promise<boolean> {
|
||||
const events = await this.deadLetterService.getFailedEvents(1000);
|
||||
const event = events.find((e) => e.eventId === eventId);
|
||||
|
||||
if (!event) {
|
||||
this.logger.warn(`Event not found: ${eventId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.eventPublisher.publish(event.topic, {
|
||||
eventId: event.eventId,
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
aggregateId: event.aggregateId,
|
||||
aggregateType: event.aggregateType,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
});
|
||||
|
||||
await this.deadLetterService.markAsProcessed(event.id);
|
||||
this.logger.log(`Manually retried event: ${eventId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to manually retry event: ${eventId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * from './kafka.module';
|
||||
export * from './event-publisher.service';
|
||||
export * from './event-consumer.controller';
|
||||
export * from './dead-letter.service';
|
||||
export * from './event-retry.service';
|
||||
export * from './mpc-event-consumer.service';
|
||||
export * from './blockchain-event-consumer.service';
|
||||
export * from './kafka.module';
|
||||
export * from './event-publisher.service';
|
||||
export * from './event-consumer.controller';
|
||||
export * from './dead-letter.service';
|
||||
export * from './event-retry.service';
|
||||
export * from './mpc-event-consumer.service';
|
||||
export * from './blockchain-event-consumer.service';
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventPublisherService } from './event-publisher.service';
|
||||
import { MpcEventConsumerService } from './mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
|
||||
import { OutboxPublisherService } from './outbox-publisher.service';
|
||||
import { OutboxRepository } from '../persistence/repositories/outbox.repository';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
PrismaService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
OutboxRepository,
|
||||
OutboxPublisherService,
|
||||
],
|
||||
exports: [
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
OutboxRepository,
|
||||
OutboxPublisherService,
|
||||
],
|
||||
})
|
||||
export class KafkaModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventPublisherService } from './event-publisher.service';
|
||||
import { MpcEventConsumerService } from './mpc-event-consumer.service';
|
||||
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
|
||||
import { OutboxPublisherService } from './outbox-publisher.service';
|
||||
import { OutboxRepository } from '../persistence/repositories/outbox.repository';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
PrismaService,
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
OutboxRepository,
|
||||
OutboxPublisherService,
|
||||
],
|
||||
exports: [
|
||||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
BlockchainEventConsumerService,
|
||||
OutboxRepository,
|
||||
OutboxPublisherService,
|
||||
],
|
||||
})
|
||||
export class KafkaModule {}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@
|
|||
* Updates user wallet addresses when keygen completes.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||
|
||||
|
|
@ -32,7 +37,7 @@ export interface KeygenCompletedPayload {
|
|||
threshold: string;
|
||||
extraPayload?: {
|
||||
userId: string;
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
accountSequence: string; // 格式: D + YYMMDD + 5位序号
|
||||
username: string;
|
||||
delegateShare?: {
|
||||
partyId: string;
|
||||
|
|
@ -85,15 +90,20 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
|
||||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||
const brokers = this.configService
|
||||
.get<string>('KAFKA_BROKERS')
|
||||
?.split(',') || ['localhost:9092'];
|
||||
const clientId =
|
||||
this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
|
||||
const groupId = 'identity-service-mpc-events';
|
||||
|
||||
this.logger.log(`[INIT] MPC Event Consumer initializing...`);
|
||||
this.logger.log(`[INIT] ClientId: ${clientId}`);
|
||||
this.logger.log(`[INIT] GroupId: ${groupId}`);
|
||||
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
|
||||
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`);
|
||||
this.logger.log(
|
||||
`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||
);
|
||||
|
||||
// 企业级重试配置:指数退避,最多重试约 2.5 小时
|
||||
this.kafka = new Kafka({
|
||||
|
|
@ -101,10 +111,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
brokers,
|
||||
logLevel: logLevel.WARN,
|
||||
retry: {
|
||||
initialRetryTime: 1000, // 1 秒
|
||||
maxRetryTime: 300000, // 最大 5 分钟
|
||||
retries: 15, // 最多 15 次
|
||||
multiplier: 2, // 指数退避因子
|
||||
initialRetryTime: 1000, // 1 秒
|
||||
maxRetryTime: 300000, // 最大 5 分钟
|
||||
retries: 15, // 最多 15 次
|
||||
multiplier: 2, // 指数退避因子
|
||||
restartOnFailure: async () => true,
|
||||
},
|
||||
});
|
||||
|
|
@ -119,16 +129,26 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
|
||||
this.logger.log(
|
||||
`[CONNECT] MPC Event Kafka consumer connected successfully`,
|
||||
);
|
||||
|
||||
// Subscribe to MPC topics
|
||||
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
|
||||
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
|
||||
await this.consumer.subscribe({
|
||||
topics: Object.values(MPC_TOPICS),
|
||||
fromBeginning: false,
|
||||
});
|
||||
this.logger.log(
|
||||
`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`,
|
||||
);
|
||||
|
||||
// Start consuming
|
||||
await this.startConsuming();
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Failed to connect MPC Event Kafka consumer`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,9 +189,15 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
|
||||
private async startConsuming(): Promise<void> {
|
||||
await this.consumer.run({
|
||||
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
|
||||
eachMessage: async ({
|
||||
topic,
|
||||
partition,
|
||||
message,
|
||||
}: EachMessagePayload) => {
|
||||
const offset = message.offset;
|
||||
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const value = message.value?.toString();
|
||||
|
|
@ -180,55 +206,83 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
const payload = parsed.payload || parsed;
|
||||
|
||||
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`);
|
||||
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`,
|
||||
);
|
||||
|
||||
switch (topic) {
|
||||
case MPC_TOPICS.KEYGEN_STARTED:
|
||||
this.logger.log(`[HANDLE] Processing KeygenStarted event`);
|
||||
if (this.keygenStartedHandler) {
|
||||
await this.keygenStartedHandler(payload as KeygenStartedPayload);
|
||||
await this.keygenStartedHandler(
|
||||
payload as KeygenStartedPayload,
|
||||
);
|
||||
this.logger.log(`[HANDLE] KeygenStarted handler completed`);
|
||||
} else {
|
||||
this.logger.warn(`[HANDLE] No handler registered for KeygenStarted`);
|
||||
this.logger.warn(
|
||||
`[HANDLE] No handler registered for KeygenStarted`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case MPC_TOPICS.KEYGEN_COMPLETED:
|
||||
this.logger.log(`[HANDLE] Processing KeygenCompleted event`);
|
||||
this.logger.log(`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`);
|
||||
this.logger.log(
|
||||
`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`,
|
||||
);
|
||||
if (this.keygenCompletedHandler) {
|
||||
await this.keygenCompletedHandler(payload as KeygenCompletedPayload);
|
||||
await this.keygenCompletedHandler(
|
||||
payload as KeygenCompletedPayload,
|
||||
);
|
||||
this.logger.log(`[HANDLE] KeygenCompleted handler completed`);
|
||||
} else {
|
||||
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`);
|
||||
this.logger.warn(
|
||||
`[HANDLE] No handler registered for KeygenCompleted`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case MPC_TOPICS.SIGNING_COMPLETED:
|
||||
this.logger.log(`[HANDLE] Processing SigningCompleted event`);
|
||||
if (this.signingCompletedHandler) {
|
||||
await this.signingCompletedHandler(payload as SigningCompletedPayload);
|
||||
await this.signingCompletedHandler(
|
||||
payload as SigningCompletedPayload,
|
||||
);
|
||||
this.logger.log(`[HANDLE] SigningCompleted handler completed`);
|
||||
} else {
|
||||
this.logger.warn(`[HANDLE] No handler registered for SigningCompleted`);
|
||||
this.logger.warn(
|
||||
`[HANDLE] No handler registered for SigningCompleted`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case MPC_TOPICS.SESSION_FAILED:
|
||||
this.logger.log(`[HANDLE] Processing SessionFailed event`);
|
||||
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`);
|
||||
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`);
|
||||
this.logger.log(
|
||||
`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`,
|
||||
);
|
||||
if (this.sessionFailedHandler) {
|
||||
await this.sessionFailedHandler(payload as SessionFailedPayload);
|
||||
await this.sessionFailedHandler(
|
||||
payload as SessionFailedPayload,
|
||||
);
|
||||
this.logger.log(`[HANDLE] SessionFailed handler completed`);
|
||||
} else {
|
||||
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`);
|
||||
this.logger.warn(
|
||||
`[HANDLE] No handler registered for SessionFailed`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -236,7 +290,10 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Error processing MPC event from ${topic}`, error);
|
||||
this.logger.error(
|
||||
`[ERROR] Error processing MPC event from ${topic}`,
|
||||
error,
|
||||
);
|
||||
// Re-throw to trigger Kafka retry mechanism
|
||||
// This ensures messages are not marked as consumed until successfully processed
|
||||
throw error;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue