150 lines
3.7 KiB
TypeScript
150 lines
3.7 KiB
TypeScript
import {
|
|
Entity,
|
|
Column,
|
|
PrimaryGeneratedColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
VersionColumn,
|
|
Index,
|
|
} from 'typeorm';
|
|
|
|
export enum UserRole {
|
|
USER = 'user',
|
|
ISSUER = 'issuer',
|
|
MARKET_MAKER = 'market_maker',
|
|
ADMIN = 'admin',
|
|
}
|
|
|
|
export enum UserStatus {
|
|
ACTIVE = 'active',
|
|
FROZEN = 'frozen',
|
|
DELETED = 'deleted',
|
|
}
|
|
|
|
@Entity('users')
|
|
export class User {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Index('idx_users_phone')
|
|
@Column({ type: 'varchar', length: 20, unique: true, nullable: true })
|
|
phone: string | null;
|
|
|
|
@Index('idx_users_email')
|
|
@Column({ type: 'varchar', length: 100, unique: true, nullable: true })
|
|
email: string | null;
|
|
|
|
@Column({ name: 'password_hash', type: 'varchar', length: 255 })
|
|
passwordHash: string;
|
|
|
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
|
nickname: string | null;
|
|
|
|
@Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true })
|
|
avatarUrl: string | null;
|
|
|
|
@Column({ name: 'kyc_level', type: 'smallint', default: 0 })
|
|
kycLevel: number;
|
|
|
|
@Column({
|
|
name: 'wallet_mode',
|
|
type: 'varchar',
|
|
length: 10,
|
|
default: 'standard',
|
|
})
|
|
walletMode: 'standard' | 'external' | 'pro';
|
|
|
|
@Index('idx_users_role')
|
|
@Column({
|
|
type: 'varchar',
|
|
length: 20,
|
|
default: UserRole.USER,
|
|
})
|
|
role: UserRole;
|
|
|
|
@Index('idx_users_status')
|
|
@Column({
|
|
type: 'varchar',
|
|
length: 20,
|
|
default: UserStatus.ACTIVE,
|
|
})
|
|
status: UserStatus;
|
|
|
|
@Column({ name: 'residence_state', type: 'varchar', length: 5, nullable: true })
|
|
residenceState: string | null;
|
|
|
|
@Column({ type: 'varchar', length: 5, nullable: true })
|
|
nationality: string | null;
|
|
|
|
@Column({ name: 'login_fail_count', type: 'int', default: 0 })
|
|
loginFailCount: number;
|
|
|
|
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
|
|
lockedUntil: Date | null;
|
|
|
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
|
lastLoginAt: Date | null;
|
|
|
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
updatedAt: Date;
|
|
|
|
@VersionColumn({ default: 1 })
|
|
version: number;
|
|
|
|
/* ── Domain Methods: Account Lockout ── */
|
|
|
|
/** 账号是否处于锁定状态 */
|
|
get isLocked(): boolean {
|
|
return this.lockedUntil !== null && new Date() < this.lockedUntil;
|
|
}
|
|
|
|
/** 是否可以登录 (状态正常且未锁定) */
|
|
get canLogin(): boolean {
|
|
return this.status === UserStatus.ACTIVE && !this.isLocked;
|
|
}
|
|
|
|
/** 剩余锁定秒数 (0 = 未锁定) */
|
|
get lockRemainingSeconds(): number {
|
|
if (!this.lockedUntil) return 0;
|
|
const diff = this.lockedUntil.getTime() - Date.now();
|
|
return diff > 0 ? Math.ceil(diff / 1000) : 0;
|
|
}
|
|
|
|
/**
|
|
* 记录登录成功 — 清除失败计数
|
|
*/
|
|
recordLoginSuccess(ip?: string): void {
|
|
this.loginFailCount = 0;
|
|
this.lockedUntil = null;
|
|
this.lastLoginAt = new Date();
|
|
}
|
|
|
|
/**
|
|
* 记录登录失败 — 指数退避锁定
|
|
* @param maxAttempts 触发锁定的失败次数阈值 (默认 6)
|
|
* @returns 剩余尝试次数 & 锁定信息
|
|
*/
|
|
recordLoginFailure(maxAttempts = 6): {
|
|
remainingAttempts: number;
|
|
lockMinutes?: number;
|
|
} {
|
|
this.loginFailCount += 1;
|
|
|
|
if (this.loginFailCount < maxAttempts) {
|
|
return { remainingAttempts: maxAttempts - this.loginFailCount };
|
|
}
|
|
|
|
// 指数退避: 2^(failCount - maxAttempts) 分钟,最大 1440 分钟 (24h)
|
|
const lockMinutes = Math.min(
|
|
Math.pow(2, this.loginFailCount - maxAttempts),
|
|
1440,
|
|
);
|
|
this.lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
|
|
|
|
return { remainingAttempts: 0, lockMinutes };
|
|
}
|
|
}
|