485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
import { Injectable, Inject, Logger } from '@nestjs/common';
|
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
|
import { MpcKeyShareRepository, MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
|
import {
|
|
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
|
|
} from '@/domain/services';
|
|
import {
|
|
UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode,
|
|
ChainType, Mnemonic, KYCInfo,
|
|
} from '@/domain/value-objects';
|
|
import { TokenService } from './token.service';
|
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
|
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
|
import {
|
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
|
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
|
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
|
|
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
|
|
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
|
|
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
|
|
} from '../commands';
|
|
|
|
@Injectable()
|
|
export class UserApplicationService {
|
|
private readonly logger = new Logger(UserApplicationService.name);
|
|
|
|
constructor(
|
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
|
private readonly userRepository: UserAccountRepository,
|
|
@Inject(MPC_KEY_SHARE_REPOSITORY)
|
|
private readonly mpcKeyShareRepository: MpcKeyShareRepository,
|
|
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
|
private readonly validatorService: UserValidatorService,
|
|
private readonly walletGenerator: WalletGeneratorService,
|
|
private readonly mpcWalletService: MpcWalletService,
|
|
private readonly tokenService: TokenService,
|
|
private readonly redisService: RedisService,
|
|
private readonly smsService: SmsService,
|
|
private readonly eventPublisher: EventPublisherService,
|
|
) {}
|
|
|
|
/**
|
|
* 自动创建账户 (首次打开APP)
|
|
*
|
|
* 使用 MPC 2-of-3 协议生成钱包地址:
|
|
* - 生成三条链 (BSC/KAVA/DST) 的钱包地址
|
|
* - 计算地址摘要并用 MPC 签名
|
|
* - 签名存储在数据库中用于防止地址被篡改
|
|
*/
|
|
async autoCreateAccount(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
|
this.logger.log(`Creating account with MPC 2-of-3 for device: ${command.deviceId}`);
|
|
|
|
// 1. 验证设备ID
|
|
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
|
|
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.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.generateNext();
|
|
|
|
// 4. 创建用户账户
|
|
const account = UserAccount.createAutomatic({
|
|
accountSequence,
|
|
initialDeviceId: command.deviceId,
|
|
deviceName: command.deviceName,
|
|
inviterSequence,
|
|
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
|
|
city: CityCode.create(command.cityCode || 'DEFAULT'),
|
|
});
|
|
|
|
// 5. 使用 MPC 2-of-3 生成三链钱包地址
|
|
this.logger.log(`Generating MPC wallet for account sequence: ${accountSequence.value}`);
|
|
const mpcResult = await this.mpcWalletService.generateMpcWallet({
|
|
userId: account.userId.toString(),
|
|
deviceId: command.deviceId,
|
|
});
|
|
|
|
// 6. 创建钱包地址实体 (包含 MPC 签名)
|
|
const wallets = new Map<ChainType, WalletAddress>();
|
|
for (const walletInfo of mpcResult.wallets) {
|
|
const chainType = walletInfo.chainType as ChainType;
|
|
const wallet = WalletAddress.createMpc({
|
|
userId: account.userId,
|
|
chainType,
|
|
address: walletInfo.address,
|
|
publicKey: walletInfo.publicKey,
|
|
addressDigest: walletInfo.addressDigest,
|
|
signature: walletInfo.signature,
|
|
});
|
|
wallets.set(chainType, wallet);
|
|
}
|
|
|
|
// 7. 绑定钱包地址到账户
|
|
account.bindMultipleWalletAddresses(wallets);
|
|
|
|
// 8. 保存账户和钱包
|
|
await this.userRepository.save(account);
|
|
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
|
|
|
|
// 9. 保存服务端 MPC 分片到数据库 (用于后续签名)
|
|
await this.mpcKeyShareRepository.saveServerShare({
|
|
userId: account.userId.value,
|
|
publicKey: mpcResult.publicKey,
|
|
partyIndex: 0, // SERVER = party 0
|
|
threshold: 2,
|
|
totalParties: 3,
|
|
encryptedShareData: mpcResult.serverShareData,
|
|
});
|
|
this.logger.log(`Server MPC share saved for user: ${account.userId.toString()}`);
|
|
|
|
// 11. 生成 Token
|
|
const tokens = await this.tokenService.generateTokenPair({
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
deviceId: command.deviceId,
|
|
});
|
|
|
|
// 12. 发布领域事件
|
|
await this.eventPublisher.publishAll(account.domainEvents);
|
|
account.clearDomainEvents();
|
|
|
|
this.logger.log(`Account created successfully: sequence=${accountSequence.value}, publicKey=${mpcResult.publicKey}`);
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
referralCode: account.referralCode.value,
|
|
// MPC 模式下返回客户端分片数据 (而不是助记词)
|
|
mnemonic: '', // MPC 模式不使用助记词,返回空字符串
|
|
clientShareData: mpcResult.clientShareData, // 客户端需要安全存储的分片数据
|
|
publicKey: mpcResult.publicKey, // MPC 公钥
|
|
walletAddresses: {
|
|
kava: wallets.get(ChainType.KAVA)!.address,
|
|
dst: wallets.get(ChainType.DST)!.address,
|
|
bsc: wallets.get(ChainType.BSC)!.address,
|
|
},
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async recoverByMnemonic(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('账户已冻结或注销');
|
|
|
|
const mnemonic = Mnemonic.create(command.mnemonic);
|
|
const wallets = this.walletGenerator.recoverWalletSystem({
|
|
userId: account.userId,
|
|
mnemonic,
|
|
deviceId: command.newDeviceId,
|
|
});
|
|
|
|
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
|
|
if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) {
|
|
throw new ApplicationError('助记词错误');
|
|
}
|
|
|
|
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();
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
nickname: account.nickname,
|
|
avatarUrl: account.avatarUrl,
|
|
referralCode: account.referralCode.value,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async recoverByPhone(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('验证码错误或已过期');
|
|
|
|
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: account.avatarUrl,
|
|
referralCode: account.referralCode.value,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async autoLogin(command: AutoLoginCommand): Promise<AutoLoginResult> {
|
|
const payload = await this.tokenService.verifyRefreshToken(command.refreshToken);
|
|
const account = await this.userRepository.findById(UserId.create(payload.userId));
|
|
if (!account || !account.isActive) throw new ApplicationError('账户不存在或已冻结');
|
|
if (!account.isDeviceAuthorized(command.deviceId)) {
|
|
throw new ApplicationError('设备未授权,请重新登录', 'DEVICE_UNAUTHORIZED');
|
|
}
|
|
|
|
account.addDevice(command.deviceId);
|
|
account.recordLogin();
|
|
await this.userRepository.save(account);
|
|
|
|
const tokens = await this.tokenService.generateTokenPair({
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
deviceId: command.deviceId,
|
|
});
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async sendSmsCode(command: SendSmsCodeCommand): Promise<void> {
|
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
|
const code = this.generateSmsCode();
|
|
const cacheKey = `sms:${command.type.toLowerCase()}:${phoneNumber.value}`;
|
|
|
|
await this.smsService.sendVerificationCode(phoneNumber.value, code);
|
|
await this.redisService.set(cacheKey, code, 300);
|
|
}
|
|
|
|
async register(command: RegisterCommand): Promise<RegisterResult> {
|
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
|
const cachedCode = await this.redisService.get(`sms:register:${phoneNumber.value}`);
|
|
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
|
|
|
const phoneValidation = await this.validatorService.validatePhoneNumber(phoneNumber);
|
|
if (!phoneValidation.isValid) throw new ApplicationError(phoneValidation.errorMessage!);
|
|
|
|
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;
|
|
}
|
|
|
|
const accountSequence = await this.sequenceGenerator.generateNext();
|
|
|
|
const account = UserAccount.create({
|
|
accountSequence,
|
|
phoneNumber,
|
|
initialDeviceId: command.deviceId,
|
|
deviceName: command.deviceName,
|
|
inviterSequence,
|
|
province: ProvinceCode.create(command.provinceCode),
|
|
city: CityCode.create(command.cityCode),
|
|
});
|
|
|
|
await this.userRepository.save(account);
|
|
await this.redisService.delete(`sms:register:${phoneNumber.value}`);
|
|
await this.eventPublisher.publishAll(account.domainEvents);
|
|
account.clearDomainEvents();
|
|
|
|
const tokens = await this.tokenService.generateTokenPair({
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
deviceId: command.deviceId,
|
|
});
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
referralCode: account.referralCode.value,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async login(command: LoginCommand): Promise<LoginResult> {
|
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
|
const cachedCode = await this.redisService.get(`sms:login:${phoneNumber.value}`);
|
|
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
|
|
|
const account = await this.userRepository.findByPhoneNumber(phoneNumber);
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
|
|
|
account.addDevice(command.deviceId);
|
|
account.recordLogin();
|
|
await this.userRepository.save(account);
|
|
await this.redisService.delete(`sms:login:${phoneNumber.value}`);
|
|
|
|
const tokens = await this.tokenService.generateTokenPair({
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
deviceId: command.deviceId,
|
|
});
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
async bindPhoneNumber(command: BindPhoneNumberCommand): 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();
|
|
}
|
|
|
|
async updateProfile(command: UpdateProfileCommand): Promise<void> {
|
|
const account = await this.userRepository.findById(UserId.create(command.userId));
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
|
|
account.updateProfile({
|
|
nickname: command.nickname,
|
|
avatarUrl: command.avatarUrl,
|
|
address: command.address,
|
|
});
|
|
|
|
await this.userRepository.save(account);
|
|
}
|
|
|
|
async submitKYC(command: SubmitKYCCommand): Promise<void> {
|
|
const account = await this.userRepository.findById(UserId.create(command.userId));
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
|
|
const kycInfo = KYCInfo.create({
|
|
realName: command.realName,
|
|
idCardNumber: command.idCardNumber,
|
|
idCardFrontUrl: command.idCardFrontUrl,
|
|
idCardBackUrl: command.idCardBackUrl,
|
|
});
|
|
|
|
account.submitKYC(kycInfo);
|
|
await this.userRepository.save(account);
|
|
await this.eventPublisher.publishAll(account.domainEvents);
|
|
account.clearDomainEvents();
|
|
}
|
|
|
|
async reviewKYC(command: ReviewKYCCommand): Promise<void> {
|
|
const account = await this.userRepository.findById(UserId.create(command.userId));
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
|
|
if (command.approved) {
|
|
account.approveKYC();
|
|
} else {
|
|
account.rejectKYC(command.reason || '审核未通过');
|
|
}
|
|
|
|
await this.userRepository.save(account);
|
|
await this.eventPublisher.publishAll(account.domainEvents);
|
|
account.clearDomainEvents();
|
|
}
|
|
|
|
async getMyDevices(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,
|
|
}));
|
|
}
|
|
|
|
async removeDevice(command: RemoveDeviceCommand): Promise<void> {
|
|
const account = await this.userRepository.findById(UserId.create(command.userId));
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
if (command.deviceIdToRemove === command.currentDeviceId) {
|
|
throw new ApplicationError('不能删除当前设备');
|
|
}
|
|
|
|
account.removeDevice(command.deviceIdToRemove);
|
|
await this.userRepository.save(account);
|
|
await this.tokenService.revokeDeviceTokens(account.userId.toString(), command.deviceIdToRemove);
|
|
await this.eventPublisher.publishAll(account.domainEvents);
|
|
account.clearDomainEvents();
|
|
}
|
|
|
|
async getMyProfile(query: GetMyProfileQuery): Promise<UserProfileDTO> {
|
|
const account = await this.userRepository.findById(UserId.create(query.userId));
|
|
if (!account) throw new ApplicationError('用户不存在');
|
|
return this.toUserProfileDTO(account);
|
|
}
|
|
|
|
async getUserByReferralCode(query: GetUserByReferralCodeQuery): Promise<UserBriefDTO | null> {
|
|
const account = await this.userRepository.findByReferralCode(ReferralCode.create(query.referralCode));
|
|
if (!account) return null;
|
|
|
|
return {
|
|
userId: account.userId.toString(),
|
|
accountSequence: account.accountSequence.value,
|
|
nickname: account.nickname,
|
|
avatarUrl: account.avatarUrl,
|
|
};
|
|
}
|
|
|
|
private toUserProfileDTO(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,
|
|
province: account.province.value,
|
|
city: account.city.value,
|
|
address: account.addressDetail,
|
|
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,
|
|
};
|
|
}
|
|
|
|
private generateSmsCode(): string {
|
|
return String(Math.floor(100000 + Math.random() * 900000));
|
|
}
|
|
}
|