524 lines
15 KiB
TypeScript
524 lines
15 KiB
TypeScript
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(),
|
||
accountSequence: this.accountSequence.value,
|
||
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',
|
||
inviterSequence: null, // 重试时不需要邀请人信息
|
||
registeredAt: this._registeredAt,
|
||
});
|
||
}
|
||
}
|