463 lines
11 KiB
TypeScript
463 lines
11 KiB
TypeScript
import { AccountSequence, Password, Phone } from '../value-objects';
|
||
|
||
export enum UserStatus {
|
||
ACTIVE = 'ACTIVE',
|
||
DISABLED = 'DISABLED',
|
||
DELETED = 'DELETED',
|
||
}
|
||
|
||
export enum KycStatus {
|
||
PENDING = 'PENDING',
|
||
SUBMITTED = 'SUBMITTED',
|
||
VERIFIED = 'VERIFIED',
|
||
REJECTED = 'REJECTED',
|
||
}
|
||
|
||
export interface UserProps {
|
||
id?: bigint;
|
||
phone: Phone;
|
||
passwordHash: string;
|
||
tradePasswordHash?: string; // 支付密码(独立于登录密码)
|
||
accountSequence: AccountSequence;
|
||
status: UserStatus;
|
||
kycStatus: KycStatus;
|
||
realName?: string;
|
||
idCardNo?: string;
|
||
idCardFront?: string;
|
||
idCardBack?: string;
|
||
kycSubmittedAt?: Date;
|
||
kycVerifiedAt?: Date;
|
||
kycRejectReason?: string;
|
||
loginFailCount: number;
|
||
lockedUntil?: Date;
|
||
lastLoginAt?: Date;
|
||
lastLoginIp?: string;
|
||
createdAt?: Date;
|
||
updatedAt?: Date;
|
||
}
|
||
|
||
/**
|
||
* 用户聚合根
|
||
*/
|
||
export class UserAggregate {
|
||
private _id?: bigint;
|
||
private _phone: Phone;
|
||
private _passwordHash: string;
|
||
private _tradePasswordHash?: string; // 支付密码哈希
|
||
private _accountSequence: AccountSequence;
|
||
private _status: UserStatus;
|
||
private _kycStatus: KycStatus;
|
||
private _realName?: string;
|
||
private _idCardNo?: string;
|
||
private _idCardFront?: string;
|
||
private _idCardBack?: string;
|
||
private _kycSubmittedAt?: Date;
|
||
private _kycVerifiedAt?: Date;
|
||
private _kycRejectReason?: string;
|
||
private _loginFailCount: number;
|
||
private _lockedUntil?: Date;
|
||
private _lastLoginAt?: Date;
|
||
private _lastLoginIp?: string;
|
||
private _createdAt?: Date;
|
||
private _updatedAt?: Date;
|
||
|
||
private constructor(props: UserProps) {
|
||
this._id = props.id;
|
||
this._phone = props.phone;
|
||
this._passwordHash = props.passwordHash;
|
||
this._tradePasswordHash = props.tradePasswordHash;
|
||
this._accountSequence = props.accountSequence;
|
||
this._status = props.status;
|
||
this._kycStatus = props.kycStatus;
|
||
this._realName = props.realName;
|
||
this._idCardNo = props.idCardNo;
|
||
this._idCardFront = props.idCardFront;
|
||
this._idCardBack = props.idCardBack;
|
||
this._kycSubmittedAt = props.kycSubmittedAt;
|
||
this._kycVerifiedAt = props.kycVerifiedAt;
|
||
this._kycRejectReason = props.kycRejectReason;
|
||
this._loginFailCount = props.loginFailCount;
|
||
this._lockedUntil = props.lockedUntil;
|
||
this._lastLoginAt = props.lastLoginAt;
|
||
this._lastLoginIp = props.lastLoginIp;
|
||
this._createdAt = props.createdAt;
|
||
this._updatedAt = props.updatedAt;
|
||
}
|
||
|
||
/**
|
||
* 创建新用户(注册)
|
||
*/
|
||
static async create(
|
||
phone: Phone,
|
||
plainPassword: string,
|
||
accountSequence: AccountSequence,
|
||
): Promise<UserAggregate> {
|
||
const password = await Password.create(plainPassword);
|
||
return new UserAggregate({
|
||
phone,
|
||
passwordHash: password.hash,
|
||
accountSequence,
|
||
status: UserStatus.ACTIVE,
|
||
kycStatus: KycStatus.PENDING,
|
||
loginFailCount: 0,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 从数据库重建
|
||
*/
|
||
static reconstitute(props: UserProps): UserAggregate {
|
||
return new UserAggregate(props);
|
||
}
|
||
|
||
// Getters
|
||
get id(): bigint | undefined {
|
||
return this._id;
|
||
}
|
||
|
||
get phone(): Phone {
|
||
return this._phone;
|
||
}
|
||
|
||
get passwordHash(): string {
|
||
return this._passwordHash;
|
||
}
|
||
|
||
get tradePasswordHash(): string | undefined {
|
||
return this._tradePasswordHash;
|
||
}
|
||
|
||
/**
|
||
* 是否已设置支付密码
|
||
*/
|
||
get hasTradePassword(): boolean {
|
||
return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null;
|
||
}
|
||
|
||
get accountSequence(): AccountSequence {
|
||
return this._accountSequence;
|
||
}
|
||
|
||
get status(): UserStatus {
|
||
return this._status;
|
||
}
|
||
|
||
get kycStatus(): KycStatus {
|
||
return this._kycStatus;
|
||
}
|
||
|
||
get realName(): string | undefined {
|
||
return this._realName;
|
||
}
|
||
|
||
get idCardNo(): string | undefined {
|
||
return this._idCardNo;
|
||
}
|
||
|
||
get idCardFront(): string | undefined {
|
||
return this._idCardFront;
|
||
}
|
||
|
||
get idCardBack(): string | undefined {
|
||
return this._idCardBack;
|
||
}
|
||
|
||
get kycSubmittedAt(): Date | undefined {
|
||
return this._kycSubmittedAt;
|
||
}
|
||
|
||
get kycVerifiedAt(): Date | undefined {
|
||
return this._kycVerifiedAt;
|
||
}
|
||
|
||
get kycRejectReason(): string | undefined {
|
||
return this._kycRejectReason;
|
||
}
|
||
|
||
get loginFailCount(): number {
|
||
return this._loginFailCount;
|
||
}
|
||
|
||
get lockedUntil(): Date | undefined {
|
||
return this._lockedUntil;
|
||
}
|
||
|
||
get lastLoginAt(): Date | undefined {
|
||
return this._lastLoginAt;
|
||
}
|
||
|
||
get lastLoginIp(): string | undefined {
|
||
return this._lastLoginIp;
|
||
}
|
||
|
||
get createdAt(): Date | undefined {
|
||
return this._createdAt;
|
||
}
|
||
|
||
get updatedAt(): Date | undefined {
|
||
return this._updatedAt;
|
||
}
|
||
|
||
/**
|
||
* 判断用户来源
|
||
*/
|
||
get source(): 'V1' | 'V2' {
|
||
return this._accountSequence.source;
|
||
}
|
||
|
||
/**
|
||
* 是否为 V1 迁移用户
|
||
*/
|
||
get isLegacyUser(): boolean {
|
||
return this._accountSequence.isV1;
|
||
}
|
||
|
||
/**
|
||
* 是否已锁定
|
||
*/
|
||
get isLocked(): boolean {
|
||
return this._lockedUntil !== undefined && this._lockedUntil > new Date();
|
||
}
|
||
|
||
/**
|
||
* 是否可登录
|
||
*/
|
||
get canLogin(): boolean {
|
||
return this._status === UserStatus.ACTIVE && !this.isLocked;
|
||
}
|
||
|
||
/**
|
||
* 是否已完成 KYC
|
||
*/
|
||
get isKycVerified(): boolean {
|
||
return this._kycStatus === KycStatus.VERIFIED;
|
||
}
|
||
|
||
/**
|
||
* 验证密码
|
||
*/
|
||
async verifyPassword(plainPassword: string): Promise<boolean> {
|
||
const password = Password.fromHash(this._passwordHash);
|
||
return password.verify(plainPassword);
|
||
}
|
||
|
||
/**
|
||
* 修改密码
|
||
*/
|
||
async changePassword(newPlainPassword: string): Promise<void> {
|
||
const password = await Password.create(newPlainPassword);
|
||
this._passwordHash = password.hash;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 设置支付密码
|
||
*/
|
||
async setTradePassword(newPlainPassword: string): Promise<void> {
|
||
const password = await Password.create(newPlainPassword);
|
||
this._tradePasswordHash = password.hash;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 验证支付密码
|
||
*/
|
||
async verifyTradePassword(plainPassword: string): Promise<boolean> {
|
||
if (!this._tradePasswordHash) {
|
||
return false;
|
||
}
|
||
const password = Password.fromHash(this._tradePasswordHash);
|
||
return password.verify(plainPassword);
|
||
}
|
||
|
||
/**
|
||
* 清除支付密码
|
||
*/
|
||
clearTradePassword(): void {
|
||
this._tradePasswordHash = undefined;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 记录登录成功
|
||
*/
|
||
recordLoginSuccess(ip?: string): void {
|
||
this._loginFailCount = 0;
|
||
this._lockedUntil = undefined;
|
||
this._lastLoginAt = new Date();
|
||
this._lastLoginIp = ip;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 计算指数退避锁定时间(分钟)
|
||
* 第6次失败: 1分钟
|
||
* 第7次失败: 2分钟
|
||
* 第8次失败: 4分钟
|
||
* 第9次失败: 8分钟
|
||
* 第10次失败: 16分钟
|
||
* ...以此类推,最长24小时
|
||
*/
|
||
private calculateLockMinutes(failCount: number, maxAttempts: number): number {
|
||
const excessAttempts = failCount - maxAttempts;
|
||
// 2^(excessAttempts) 分钟,最长 1440 分钟(24小时)
|
||
const lockMinutes = Math.pow(2, excessAttempts);
|
||
return Math.min(lockMinutes, 1440);
|
||
}
|
||
|
||
/**
|
||
* 记录登录失败
|
||
* @param maxAttempts 最大尝试次数,默认6次
|
||
* @returns 返回剩余尝试次数和锁定信息
|
||
*/
|
||
recordLoginFailure(maxAttempts: number = 6): { remainingAttempts: number; lockedUntil?: Date; lockMinutes?: number } {
|
||
this._loginFailCount += 1;
|
||
this._updatedAt = new Date();
|
||
|
||
if (this._loginFailCount >= maxAttempts) {
|
||
const lockMinutes = this.calculateLockMinutes(this._loginFailCount, maxAttempts);
|
||
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
|
||
return {
|
||
remainingAttempts: 0,
|
||
lockedUntil: this._lockedUntil,
|
||
lockMinutes,
|
||
};
|
||
}
|
||
|
||
return {
|
||
remainingAttempts: maxAttempts - this._loginFailCount,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取剩余尝试次数
|
||
*/
|
||
getRemainingAttempts(maxAttempts: number = 6): number {
|
||
return Math.max(0, maxAttempts - this._loginFailCount);
|
||
}
|
||
|
||
/**
|
||
* 获取锁定剩余时间(秒)
|
||
*/
|
||
getLockRemainingSeconds(): number {
|
||
if (!this._lockedUntil) return 0;
|
||
const remaining = this._lockedUntil.getTime() - Date.now();
|
||
return Math.max(0, Math.ceil(remaining / 1000));
|
||
}
|
||
|
||
/**
|
||
* 解锁账户
|
||
*/
|
||
unlock(): void {
|
||
this._loginFailCount = 0;
|
||
this._lockedUntil = undefined;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 提交 KYC 资料
|
||
*/
|
||
submitKyc(
|
||
realName: string,
|
||
idCardNo: string,
|
||
idCardFront: string,
|
||
idCardBack: string,
|
||
): void {
|
||
if (this._kycStatus === KycStatus.VERIFIED) {
|
||
throw new Error('已完成实名认证,无法重新提交');
|
||
}
|
||
|
||
this._realName = realName;
|
||
this._idCardNo = idCardNo;
|
||
this._idCardFront = idCardFront;
|
||
this._idCardBack = idCardBack;
|
||
this._kycStatus = KycStatus.SUBMITTED;
|
||
this._kycSubmittedAt = new Date();
|
||
this._kycRejectReason = undefined;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* KYC 审核通过
|
||
*/
|
||
approveKyc(): void {
|
||
if (this._kycStatus !== KycStatus.SUBMITTED) {
|
||
throw new Error('当前状态无法审核');
|
||
}
|
||
|
||
this._kycStatus = KycStatus.VERIFIED;
|
||
this._kycVerifiedAt = new Date();
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* KYC 审核拒绝
|
||
*/
|
||
rejectKyc(reason: string): void {
|
||
if (this._kycStatus !== KycStatus.SUBMITTED) {
|
||
throw new Error('当前状态无法审核');
|
||
}
|
||
|
||
this._kycStatus = KycStatus.REJECTED;
|
||
this._kycRejectReason = reason;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 禁用用户
|
||
*/
|
||
disable(): void {
|
||
this._status = UserStatus.DISABLED;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 启用用户
|
||
*/
|
||
enable(): void {
|
||
this._status = UserStatus.ACTIVE;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 删除用户(软删除)
|
||
*/
|
||
delete(): void {
|
||
this._status = UserStatus.DELETED;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
/**
|
||
* 更换手机号
|
||
*/
|
||
changePhone(newPhone: Phone): void {
|
||
this._phone = newPhone;
|
||
this._updatedAt = new Date();
|
||
}
|
||
|
||
toSnapshot(): UserProps {
|
||
return {
|
||
id: this._id,
|
||
phone: this._phone,
|
||
passwordHash: this._passwordHash,
|
||
tradePasswordHash: this._tradePasswordHash,
|
||
accountSequence: this._accountSequence,
|
||
status: this._status,
|
||
kycStatus: this._kycStatus,
|
||
realName: this._realName,
|
||
idCardNo: this._idCardNo,
|
||
idCardFront: this._idCardFront,
|
||
idCardBack: this._idCardBack,
|
||
kycSubmittedAt: this._kycSubmittedAt,
|
||
kycVerifiedAt: this._kycVerifiedAt,
|
||
kycRejectReason: this._kycRejectReason,
|
||
loginFailCount: this._loginFailCount,
|
||
lockedUntil: this._lockedUntil,
|
||
lastLoginAt: this._lastLoginAt,
|
||
lastLoginIp: this._lastLoginIp,
|
||
createdAt: this._createdAt,
|
||
updatedAt: this._updatedAt,
|
||
};
|
||
}
|
||
}
|