feat(auth-service): 添加用户认证服务2.0
实现完整的用户认证服务,支持1.0用户迁移和2.0新用户注册: 功能特性: - 用户注册(生成V2格式accountSequence: 15位) - 密码登录(支持V1迁移用户和V2用户) - V1用户首次登录自动迁移到2.0系统 - 短信验证码发送/验证(注册/登录/重置密码/更换手机号) - 密码管理(重置密码、修改密码) - KYC实名认证(提交/审核资料) - JWT认证(Access Token + Refresh Token) 技术架构: - DDD六边形架构(Domain/Application/Infrastructure/API) - Prisma ORM + PostgreSQL - CDC消费者同步1.0用户数据 - Outbox模式发布领域事件 - NestJS ThrottlerModule限流 数据模型: - User: 2.0用户表(含KYC字段) - SyncedLegacyUser: CDC同步的1.0用户(只读) - RefreshToken: 刷新令牌 - SmsVerification: 短信验证码 - DailySequenceCounter: 每日序号计数器 - OutboxEvent: 发件箱事件 AccountSequence格式: - V1: D + YYMMDD + 5位序号 = 12字符 - V2: D + YYMMDD + 8位序号 = 15字符 服务端口:3024 数据库:rwa_auth 同时更新deploy-mining.sh添加auth-service支持。 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c8c2e63da6
commit
f7278b6196
|
|
@ -0,0 +1,45 @@
|
|||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3024
|
||||
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwa_auth?schema=public"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Kafka (CDC)
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
CDC_TOPIC_USERS=dbserver1.public.users
|
||||
CDC_CONSUMER_GROUP=auth-service-cdc-group
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# SMS (阿里云短信)
|
||||
SMS_ACCESS_KEY_ID=
|
||||
SMS_ACCESS_KEY_SECRET=
|
||||
SMS_SIGN_NAME=榴莲生态
|
||||
SMS_TEMPLATE_CODE=SMS_123456789
|
||||
|
||||
# SMS Verification
|
||||
SMS_CODE_EXPIRE_SECONDS=300
|
||||
SMS_CODE_LENGTH=6
|
||||
SMS_DAILY_LIMIT=10
|
||||
|
||||
# Password
|
||||
PASSWORD_SALT_ROUNDS=12
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
PASSWORD_MAX_LENGTH=32
|
||||
|
||||
# KYC
|
||||
KYC_STORAGE_PATH=/data/kyc
|
||||
KYC_MAX_FILE_SIZE=5242880
|
||||
|
||||
# Rate Limiting
|
||||
THROTTLE_TTL=60
|
||||
THROTTLE_LIMIT=10
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"name": "auth-service",
|
||||
"version": "1.0.0",
|
||||
"description": "RWA Auth Service 2.0 - 用户认证服务(注册/登录/密码/KYC)",
|
||||
"author": "RWA Team",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"prisma": {
|
||||
"schema": "prisma/schema.prisma"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:migrate:prod": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/microservices": "^10.0.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@nestjs/throttler": "^5.1.0",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"kafkajs": "^2.2.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2.0 用户表
|
||||
// ============================================================================
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
|
||||
// 基本信息
|
||||
phone String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
|
||||
// 统一关联键 (跨所有服务)
|
||||
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
||||
// V2: 15位 (D + 6位日期 + 8位序号), 如 D25121100000008
|
||||
accountSequence String @unique @map("account_sequence")
|
||||
|
||||
// 状态
|
||||
status UserStatus @default(ACTIVE)
|
||||
|
||||
// KYC 实名认证
|
||||
kycStatus KycStatus @default(PENDING)
|
||||
realName String? @map("real_name")
|
||||
idCardNo String? @map("id_card_no")
|
||||
idCardFront String? @map("id_card_front") // 身份证正面照片路径
|
||||
idCardBack String? @map("id_card_back") // 身份证背面照片路径
|
||||
kycSubmittedAt DateTime? @map("kyc_submitted_at")
|
||||
kycVerifiedAt DateTime? @map("kyc_verified_at")
|
||||
kycRejectReason String? @map("kyc_reject_reason")
|
||||
|
||||
// 安全
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
lastLoginIp String? @map("last_login_ip")
|
||||
|
||||
// 时间戳
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
refreshTokens RefreshToken[]
|
||||
loginLogs LoginLog[]
|
||||
smsLogs SmsLog[]
|
||||
|
||||
@@index([phone])
|
||||
@@index([accountSequence])
|
||||
@@index([status])
|
||||
@@index([kycStatus])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
DELETED
|
||||
}
|
||||
|
||||
enum KycStatus {
|
||||
PENDING // 待提交
|
||||
SUBMITTED // 已提交待审核
|
||||
VERIFIED // 已认证
|
||||
REJECTED // 已拒绝
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CDC 同步的 1.0 用户(只读)
|
||||
// ============================================================================
|
||||
|
||||
model SyncedLegacyUser {
|
||||
id BigInt @id @default(autoincrement())
|
||||
|
||||
// 1.0 用户数据
|
||||
legacyId BigInt @unique @map("legacy_id") // 1.0 的 user.id
|
||||
accountSequence String @unique @map("account_sequence")
|
||||
phone String
|
||||
passwordHash String @map("password_hash")
|
||||
status String
|
||||
legacyCreatedAt DateTime @map("legacy_created_at")
|
||||
|
||||
// 迁移状态
|
||||
migratedToV2 Boolean @default(false) @map("migrated_to_v2")
|
||||
migratedAt DateTime? @map("migrated_at")
|
||||
|
||||
// CDC 元数据
|
||||
sourceSequenceNum BigInt @map("source_sequence_num")
|
||||
syncedAt DateTime @default(now()) @map("synced_at")
|
||||
|
||||
@@index([phone])
|
||||
@@index([accountSequence])
|
||||
@@index([migratedToV2])
|
||||
@@map("synced_legacy_users")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 刷新令牌
|
||||
// ============================================================================
|
||||
|
||||
model RefreshToken {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @map("user_id")
|
||||
token String @unique
|
||||
deviceInfo String? @map("device_info")
|
||||
ipAddress String? @map("ip_address")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([token])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 短信验证码
|
||||
// ============================================================================
|
||||
|
||||
model SmsVerification {
|
||||
id BigInt @id @default(autoincrement())
|
||||
phone String
|
||||
code String
|
||||
type SmsVerificationType
|
||||
expiresAt DateTime @map("expires_at")
|
||||
verifiedAt DateTime? @map("verified_at")
|
||||
attempts Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([phone, type])
|
||||
@@index([expiresAt])
|
||||
@@map("sms_verifications")
|
||||
}
|
||||
|
||||
enum SmsVerificationType {
|
||||
REGISTER // 注册
|
||||
LOGIN // 登录
|
||||
RESET_PASSWORD // 重置密码
|
||||
CHANGE_PHONE // 更换手机号
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 短信发送日志
|
||||
// ============================================================================
|
||||
|
||||
model SmsLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt? @map("user_id")
|
||||
phone String
|
||||
type SmsVerificationType
|
||||
content String?
|
||||
status SmsStatus @default(PENDING)
|
||||
provider String? // 短信服务商
|
||||
providerId String? @map("provider_id") // 服务商返回的ID
|
||||
errorMsg String? @map("error_msg")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([phone])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@map("sms_logs")
|
||||
}
|
||||
|
||||
enum SmsStatus {
|
||||
PENDING
|
||||
SENT
|
||||
DELIVERED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 登录日志
|
||||
// ============================================================================
|
||||
|
||||
model LoginLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt? @map("user_id")
|
||||
phone String
|
||||
type LoginType
|
||||
success Boolean
|
||||
failReason String? @map("fail_reason")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
deviceInfo String? @map("device_info")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([phone])
|
||||
@@index([createdAt])
|
||||
@@map("login_logs")
|
||||
}
|
||||
|
||||
enum LoginType {
|
||||
PASSWORD // 密码登录
|
||||
SMS_CODE // 验证码登录
|
||||
LEGACY_MIGRATE // 1.0 用户迁移登录
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 每日序号计数器(用于生成 accountSequence)
|
||||
// ============================================================================
|
||||
|
||||
model DailySequenceCounter {
|
||||
id BigInt @id @default(autoincrement())
|
||||
dateKey String @unique @map("date_key") // 格式: YYMMDD
|
||||
lastSeq Int @default(0) @map("last_seq")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("daily_sequence_counters")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发件箱(事件发布)
|
||||
// ============================================================================
|
||||
|
||||
model OutboxEvent {
|
||||
id BigInt @id @default(autoincrement())
|
||||
aggregateType String @map("aggregate_type")
|
||||
aggregateId String @map("aggregate_id")
|
||||
eventType String @map("event_type")
|
||||
payload Json
|
||||
topic String
|
||||
key String
|
||||
status OutboxStatus @default(PENDING)
|
||||
retryCount Int @default(0) @map("retry_count")
|
||||
maxRetries Int @default(3) @map("max_retries")
|
||||
lastError String? @map("last_error")
|
||||
publishedAt DateTime? @map("published_at")
|
||||
nextRetryAt DateTime? @map("next_retry_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([status])
|
||||
@@index([nextRetryAt])
|
||||
@@map("outbox_events")
|
||||
}
|
||||
|
||||
enum OutboxStatus {
|
||||
PENDING
|
||||
PUBLISHED
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AuthController,
|
||||
SmsController,
|
||||
PasswordController,
|
||||
KycController,
|
||||
UserController,
|
||||
HealthController,
|
||||
} from './controllers';
|
||||
import { ApplicationModule } from '@/application';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AuthController,
|
||||
SmsController,
|
||||
PasswordController,
|
||||
KycController,
|
||||
UserController,
|
||||
HealthController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Req,
|
||||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AuthService, LoginResult } from '@/application/services';
|
||||
|
||||
class RegisterDto {
|
||||
phone: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
}
|
||||
|
||||
class LoginDto {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class LoginBySmsDto {
|
||||
phone: string;
|
||||
smsCode: string;
|
||||
}
|
||||
|
||||
class RefreshTokenDto {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* POST /auth/register
|
||||
*/
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@Headers('X-Device-Info') deviceInfo?: string,
|
||||
@Req() req?: any,
|
||||
): Promise<{ success: boolean; data: LoginResult }> {
|
||||
const result = await this.authService.register({
|
||||
phone: dto.phone,
|
||||
password: dto.password,
|
||||
smsCode: dto.smsCode,
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录
|
||||
* POST /auth/login
|
||||
*/
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Headers('X-Device-Info') deviceInfo?: string,
|
||||
@Req() req?: any,
|
||||
): Promise<{ success: boolean; data: LoginResult }> {
|
||||
const ipAddress = req?.ip || req?.connection?.remoteAddress;
|
||||
const result = await this.authService.loginByPassword({
|
||||
phone: dto.phone,
|
||||
password: dto.password,
|
||||
deviceInfo,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
* POST /auth/refresh
|
||||
*/
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
): Promise<{ success: boolean; data: { accessToken: string; expiresIn: number } }> {
|
||||
const result = await this.authService.refreshToken(dto.refreshToken);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
* POST /auth/logout
|
||||
*/
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async check(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'auth-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
async ready(): Promise<{ status: string; database: string }> {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { status: 'ready', database: 'connected' };
|
||||
} catch (error) {
|
||||
return { status: 'not ready', database: 'disconnected' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './auth.controller';
|
||||
export * from './sms.controller';
|
||||
export * from './password.controller';
|
||||
export * from './kyc.controller';
|
||||
export * from './user.controller';
|
||||
export * from './health.controller';
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { KycService, KycStatusResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
|
||||
class SubmitKycDto {
|
||||
realName: string;
|
||||
idCardNo: string;
|
||||
}
|
||||
|
||||
@Controller('kyc')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class KycController {
|
||||
constructor(private readonly kycService: KycService) {}
|
||||
|
||||
/**
|
||||
* 获取 KYC 状态
|
||||
* GET /kyc/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
): Promise<{ success: boolean; data: KycStatusResult }> {
|
||||
const result = await this.kycService.getKycStatus(user.accountSequence);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 KYC 资料
|
||||
* POST /kyc/submit
|
||||
*/
|
||||
@Post('submit')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FilesInterceptor('files', 2))
|
||||
async submitKyc(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
@Body() dto: SubmitKycDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
): Promise<{ success: boolean }> {
|
||||
// TODO: 保存上传的文件并获取路径
|
||||
const idCardFront = files?.[0]?.path || 'placeholder-front';
|
||||
const idCardBack = files?.[1]?.path || 'placeholder-back';
|
||||
|
||||
await this.kycService.submitKyc({
|
||||
accountSequence: user.accountSequence,
|
||||
realName: dto.realName,
|
||||
idCardNo: dto.idCardNo,
|
||||
idCardFront,
|
||||
idCardBack,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { PasswordService } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
|
||||
class ResetPasswordDto {
|
||||
phone: string;
|
||||
smsCode: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
class ChangePasswordDto {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@Controller('password')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class PasswordController {
|
||||
constructor(private readonly passwordService: PasswordService) {}
|
||||
|
||||
/**
|
||||
* 重置密码(通过短信验证码)
|
||||
* POST /password/reset
|
||||
*/
|
||||
@Post('reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(
|
||||
@Body() dto: ResetPasswordDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.passwordService.resetPassword(dto);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(需要登录)
|
||||
* POST /password/change
|
||||
*/
|
||||
@Post('change')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async changePassword(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
@Body() dto: ChangePasswordDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.passwordService.changePassword({
|
||||
accountSequence: user.accountSequence,
|
||||
oldPassword: dto.oldPassword,
|
||||
newPassword: dto.newPassword,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { SmsService } from '@/application/services';
|
||||
import { SmsVerificationType } from '@/domain';
|
||||
|
||||
class SendSmsDto {
|
||||
phone: string;
|
||||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
||||
}
|
||||
|
||||
class VerifySmsDto {
|
||||
phone: string;
|
||||
code: string;
|
||||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
||||
}
|
||||
|
||||
@Controller('sms')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class SmsController {
|
||||
constructor(private readonly smsService: SmsService) {}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* POST /sms/send
|
||||
*/
|
||||
@Post('send')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendCode(
|
||||
@Body() dto: SendSmsDto,
|
||||
): Promise<{ success: boolean; data: { expiresIn: number } }> {
|
||||
const result = await this.smsService.sendCode({
|
||||
phone: dto.phone,
|
||||
type: dto.type as SmsVerificationType,
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* POST /sms/verify
|
||||
*/
|
||||
@Post('verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyCode(
|
||||
@Body() dto: VerifySmsDto,
|
||||
): Promise<{ success: boolean; data: { valid: boolean } }> {
|
||||
const valid = await this.smsService.verifyCode({
|
||||
phone: dto.phone,
|
||||
code: dto.code,
|
||||
type: dto.type as SmsVerificationType,
|
||||
});
|
||||
|
||||
return { success: true, data: { valid } };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { UserService, UserProfileResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
|
||||
@Controller('user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* GET /user/profile
|
||||
*/
|
||||
@Get('profile')
|
||||
async getProfile(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
): Promise<{ success: boolean; data: UserProfileResult }> {
|
||||
const result = await this.userService.getProfile(user.accountSequence);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { InfrastructureModule } from './infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// 限流模块
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 1 minute
|
||||
limit: 10, // 10 requests per minute
|
||||
},
|
||||
]),
|
||||
|
||||
// 基础设施模块
|
||||
InfrastructureModule,
|
||||
|
||||
// API 模块
|
||||
ApiModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AuthService,
|
||||
PasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
OutboxService,
|
||||
} from './services';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
InfrastructureModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
OutboxService,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './application.module';
|
||||
export * from './services';
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import { Injectable, Inject, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
UserAggregate,
|
||||
Phone,
|
||||
AccountSequence,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
SyncedLegacyUserRepository,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
RefreshTokenRepository,
|
||||
UserRegisteredEvent,
|
||||
LegacyUserMigratedEvent,
|
||||
} from '@/domain';
|
||||
import { OutboxService } from './outbox.service';
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: {
|
||||
accountSequence: string;
|
||||
phone: string;
|
||||
source: 'V1' | 'V2';
|
||||
kycStatus: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RegisterDto {
|
||||
phone: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
}
|
||||
|
||||
export interface LoginDto {
|
||||
phone: string;
|
||||
password: string;
|
||||
deviceInfo?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export interface LoginBySmsDto {
|
||||
phone: string;
|
||||
smsCode: string;
|
||||
deviceInfo?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
|
||||
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||
@Inject(REFRESH_TOKEN_REPOSITORY)
|
||||
private readonly refreshTokenRepository: RefreshTokenRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly outboxService: OutboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册(2.0 新用户)
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<LoginResult> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
|
||||
// 检查手机号是否已存在于 V2 用户
|
||||
const existingUser = await this.userRepository.findByPhone(phone);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('手机号已注册');
|
||||
}
|
||||
|
||||
// 检查是否存在未迁移的 V1 用户
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||
if (legacyUser && !legacyUser.migratedToV2) {
|
||||
throw new ConflictException('此手机号已在1.0系统注册,请直接登录进行迁移');
|
||||
}
|
||||
|
||||
// 生成 V2 账户序列号
|
||||
const accountSequence = await this.userRepository.getNextAccountSequence();
|
||||
|
||||
// 创建新用户
|
||||
const user = await UserAggregate.create(phone, dto.password, accountSequence);
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
|
||||
// 发布用户注册事件
|
||||
await this.outboxService.publish(
|
||||
new UserRegisteredEvent(
|
||||
accountSequence.value,
|
||||
phone.value,
|
||||
'V2',
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
|
||||
// 生成 tokens
|
||||
return this.generateTokens(savedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录
|
||||
*/
|
||||
async loginByPassword(dto: LoginDto): Promise<LoginResult> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
|
||||
// 先尝试从 V2 用户表查找
|
||||
let user = await this.userRepository.findByPhone(phone);
|
||||
|
||||
if (user) {
|
||||
// V2 用户登录
|
||||
return this.handleV2Login(user, dto.password, dto.deviceInfo, dto.ipAddress);
|
||||
}
|
||||
|
||||
// 尝试从同步的 V1 用户表查找
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||
if (!legacyUser) {
|
||||
throw new UnauthorizedException('手机号或密码错误');
|
||||
}
|
||||
|
||||
if (legacyUser.migratedToV2) {
|
||||
// 已迁移但找不到 V2 用户,数据异常
|
||||
throw new UnauthorizedException('账户状态异常,请联系客服');
|
||||
}
|
||||
|
||||
// V1 用户首次登录,进行迁移
|
||||
return this.migrateAndLogin(legacyUser, dto.password, dto.deviceInfo, dto.ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 V2 用户登录
|
||||
*/
|
||||
private async handleV2Login(
|
||||
user: UserAggregate,
|
||||
password: string,
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<LoginResult> {
|
||||
if (!user.canLogin) {
|
||||
if (user.isLocked) {
|
||||
throw new UnauthorizedException('账户已被锁定,请稍后再试');
|
||||
}
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
|
||||
const isValid = await user.verifyPassword(password);
|
||||
if (!isValid) {
|
||||
user.recordLoginFailure();
|
||||
await this.userRepository.save(user);
|
||||
throw new UnauthorizedException('手机号或密码错误');
|
||||
}
|
||||
|
||||
user.recordLoginSuccess(ipAddress);
|
||||
await this.userRepository.save(user);
|
||||
|
||||
return this.generateTokens(user, deviceInfo, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移 V1 用户并登录
|
||||
*/
|
||||
private async migrateAndLogin(
|
||||
legacyUser: { accountSequence: AccountSequence; phone: Phone; passwordHash: string },
|
||||
password: string,
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<LoginResult> {
|
||||
// 验证 V1 用户密码
|
||||
const bcrypt = await import('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, legacyUser.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('手机号或密码错误');
|
||||
}
|
||||
|
||||
// 创建 V2 用户(保留原 accountSequence)
|
||||
const user = UserAggregate.reconstitute({
|
||||
phone: legacyUser.phone,
|
||||
passwordHash: legacyUser.passwordHash,
|
||||
accountSequence: legacyUser.accountSequence,
|
||||
status: 'ACTIVE' as any,
|
||||
kycStatus: 'PENDING' as any,
|
||||
loginFailCount: 0,
|
||||
});
|
||||
|
||||
user.recordLoginSuccess(ipAddress);
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
|
||||
// 标记 V1 用户已迁移
|
||||
await this.syncedLegacyUserRepository.markAsMigrated(legacyUser.accountSequence);
|
||||
|
||||
// 发布迁移事件
|
||||
await this.outboxService.publish(
|
||||
new LegacyUserMigratedEvent(
|
||||
legacyUser.accountSequence.value,
|
||||
legacyUser.phone.value,
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
|
||||
return this.generateTokens(savedUser, deviceInfo, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
async refreshToken(token: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const tokenData = await this.refreshTokenRepository.findByToken(token);
|
||||
if (!tokenData || tokenData.revokedAt || tokenData.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('无效的刷新令牌');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findById(tokenData.userId);
|
||||
if (!user || !user.canLogin) {
|
||||
throw new UnauthorizedException('账户不可用');
|
||||
}
|
||||
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
||||
|
||||
return { accessToken, expiresIn };
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
await this.refreshTokenRepository.revoke(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出所有设备
|
||||
*/
|
||||
async logoutAll(userId: bigint): Promise<void> {
|
||||
await this.refreshTokenRepository.revokeAllByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 tokens
|
||||
*/
|
||||
private async generateTokens(
|
||||
user: UserAggregate,
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<LoginResult> {
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
const refreshToken = await this.generateRefreshToken(user, deviceInfo, ipAddress);
|
||||
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
user: {
|
||||
accountSequence: user.accountSequence.value,
|
||||
phone: user.phone.masked,
|
||||
source: user.source,
|
||||
kycStatus: user.kycStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 access token
|
||||
*/
|
||||
private generateAccessToken(user: UserAggregate): string {
|
||||
const payload = {
|
||||
sub: user.accountSequence.value,
|
||||
phone: user.phone.value,
|
||||
source: user.source,
|
||||
};
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 refresh token
|
||||
*/
|
||||
private async generateRefreshToken(
|
||||
user: UserAggregate,
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
const token = crypto.randomBytes(64).toString('hex');
|
||||
const expiresInDays = this.configService.get<number>('JWT_REFRESH_EXPIRES_DAYS', 30);
|
||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.refreshTokenRepository.create({
|
||||
userId: user.id!,
|
||||
token,
|
||||
deviceInfo,
|
||||
ipAddress,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './auth.service';
|
||||
export * from './password.service';
|
||||
export * from './sms.service';
|
||||
export * from './kyc.service';
|
||||
export * from './user.service';
|
||||
export * from './outbox.service';
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AccountSequence,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
UserKycVerifiedEvent,
|
||||
} from '@/domain';
|
||||
import { OutboxService } from './outbox.service';
|
||||
|
||||
export interface SubmitKycDto {
|
||||
accountSequence: string;
|
||||
realName: string;
|
||||
idCardNo: string;
|
||||
idCardFront: string; // 文件路径或 base64
|
||||
idCardBack: string;
|
||||
}
|
||||
|
||||
export interface ReviewKycDto {
|
||||
accountSequence: string;
|
||||
approved: boolean;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
export interface KycStatusResult {
|
||||
status: string;
|
||||
realName?: string;
|
||||
idCardNo?: string; // 脱敏后的
|
||||
submittedAt?: Date;
|
||||
verifiedAt?: Date;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KycService {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly outboxService: OutboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 提交 KYC 资料
|
||||
*/
|
||||
async submitKyc(dto: SubmitKycDto): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 验证身份证号格式
|
||||
if (!this.isValidIdCard(dto.idCardNo)) {
|
||||
throw new BadRequestException('身份证号格式无效');
|
||||
}
|
||||
|
||||
// 验证姓名格式
|
||||
if (!this.isValidRealName(dto.realName)) {
|
||||
throw new BadRequestException('姓名格式无效');
|
||||
}
|
||||
|
||||
// 保存 KYC 资料
|
||||
user.submitKyc(dto.realName, dto.idCardNo, dto.idCardFront, dto.idCardBack);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核 KYC(管理员)
|
||||
*/
|
||||
async reviewKyc(dto: ReviewKycDto): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
if (dto.approved) {
|
||||
user.approveKyc();
|
||||
|
||||
// 发布 KYC 认证通过事件
|
||||
await this.outboxService.publish(
|
||||
new UserKycVerifiedEvent(
|
||||
user.accountSequence.value,
|
||||
user.realName!,
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (!dto.rejectReason) {
|
||||
throw new BadRequestException('拒绝原因不能为空');
|
||||
}
|
||||
user.rejectKyc(dto.rejectReason);
|
||||
}
|
||||
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 KYC 状态
|
||||
*/
|
||||
async getKycStatus(accountSequence: string): Promise<KycStatusResult> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
status: user.kycStatus,
|
||||
realName: user.realName ? this.maskName(user.realName) : undefined,
|
||||
idCardNo: user.idCardNo ? this.maskIdCard(user.idCardNo) : undefined,
|
||||
submittedAt: user.kycSubmittedAt,
|
||||
verifiedAt: user.kycVerifiedAt,
|
||||
rejectReason: user.kycRejectReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证号格式(18位)
|
||||
*/
|
||||
private isValidIdCard(idCard: string): boolean {
|
||||
const pattern = /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
|
||||
if (!pattern.test(idCard)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 校验码验证
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) {
|
||||
sum += parseInt(idCard[i]) * weights[i];
|
||||
}
|
||||
const checkCode = checkCodes[sum % 11];
|
||||
return idCard[17].toUpperCase() === checkCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证姓名格式
|
||||
*/
|
||||
private isValidRealName(name: string): boolean {
|
||||
// 中文姓名 2-20 个字符
|
||||
const pattern = /^[\u4e00-\u9fa5·]{2,20}$/;
|
||||
return pattern.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏姓名(保留第一个字)
|
||||
*/
|
||||
private maskName(name: string): string {
|
||||
if (name.length <= 1) return name;
|
||||
return name[0] + '*'.repeat(name.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏身份证号(保留前4后4)
|
||||
*/
|
||||
private maskIdCard(idCard: string): string {
|
||||
if (idCard.length !== 18) return idCard;
|
||||
return idCard.slice(0, 4) + '*'.repeat(10) + idCard.slice(-4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserRegisteredEvent, UserKycVerifiedEvent, LegacyUserMigratedEvent } from '@/domain';
|
||||
|
||||
type DomainEvent = UserRegisteredEvent | UserKycVerifiedEvent | LegacyUserMigratedEvent;
|
||||
|
||||
/**
|
||||
* Outbox 服务 - 用于发布领域事件
|
||||
* 实现发件箱模式,确保事件可靠发布
|
||||
*/
|
||||
@Injectable()
|
||||
export class OutboxService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 发布领域事件到 Outbox
|
||||
*/
|
||||
async publish(event: DomainEvent): Promise<void> {
|
||||
const eventType = this.getEventType(event);
|
||||
const topic = this.getTopicForEvent(event);
|
||||
const payload = event.toPayload();
|
||||
|
||||
await this.prisma.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: 'User',
|
||||
aggregateId: this.getAggregateId(event),
|
||||
eventType,
|
||||
payload: payload as any,
|
||||
topic,
|
||||
key: this.getAggregateId(event),
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getEventType(event: DomainEvent): string {
|
||||
if (event instanceof UserRegisteredEvent) {
|
||||
return UserRegisteredEvent.EVENT_TYPE;
|
||||
}
|
||||
if (event instanceof UserKycVerifiedEvent) {
|
||||
return UserKycVerifiedEvent.EVENT_TYPE;
|
||||
}
|
||||
if (event instanceof LegacyUserMigratedEvent) {
|
||||
return LegacyUserMigratedEvent.EVENT_TYPE;
|
||||
}
|
||||
throw new Error('Unknown event type');
|
||||
}
|
||||
|
||||
private getTopicForEvent(event: DomainEvent): string {
|
||||
// 所有用户相关事件发到同一个 topic
|
||||
return 'auth.events';
|
||||
}
|
||||
|
||||
private getAggregateId(event: DomainEvent): string {
|
||||
return event.accountSequence;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Phone,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
SyncedLegacyUserRepository,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
SmsVerificationRepository,
|
||||
SmsVerificationType,
|
||||
} from '@/domain';
|
||||
|
||||
export interface ResetPasswordDto {
|
||||
phone: string;
|
||||
smsCode: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordDto {
|
||||
accountSequence: string;
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
|
||||
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||
@Inject(SMS_VERIFICATION_REPOSITORY)
|
||||
private readonly smsVerificationRepository: SmsVerificationRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 重置密码(通过短信验证码)
|
||||
*/
|
||||
async resetPassword(dto: ResetPasswordDto): Promise<void> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
|
||||
// 验证短信验证码
|
||||
const verification = await this.smsVerificationRepository.findLatestValid(
|
||||
phone,
|
||||
SmsVerificationType.RESET_PASSWORD,
|
||||
);
|
||||
|
||||
if (!verification || verification.code !== dto.smsCode) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
|
||||
// 标记验证码已使用
|
||||
await this.smsVerificationRepository.markAsVerified(verification.id);
|
||||
|
||||
// 查找用户
|
||||
const user = await this.userRepository.findByPhone(phone);
|
||||
if (!user) {
|
||||
// 检查是否为未迁移的 V1 用户
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||
if (legacyUser && !legacyUser.migratedToV2) {
|
||||
throw new BadRequestException('请先登录以完成账户迁移,再修改密码');
|
||||
}
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await user.changePassword(dto.newPassword);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(需要验证旧密码)
|
||||
*/
|
||||
async changePassword(dto: ChangePasswordDto): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
new (await import('@/domain')).AccountSequence(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValid = await user.verifyPassword(dto.oldPassword);
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('原密码错误');
|
||||
}
|
||||
|
||||
// 新密码不能与旧密码相同
|
||||
const isSame = await user.verifyPassword(dto.newPassword);
|
||||
if (isSame) {
|
||||
throw new BadRequestException('新密码不能与原密码相同');
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await user.changePassword(dto.newPassword);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
Phone,
|
||||
SmsCode,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
SmsVerificationRepository,
|
||||
SmsVerificationType,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
SyncedLegacyUserRepository,
|
||||
} from '@/domain';
|
||||
|
||||
export interface SendSmsDto {
|
||||
phone: string;
|
||||
type: SmsVerificationType;
|
||||
}
|
||||
|
||||
export interface VerifySmsDto {
|
||||
phone: string;
|
||||
code: string;
|
||||
type: SmsVerificationType;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly codeExpireSeconds: number;
|
||||
private readonly dailyLimit: number;
|
||||
private readonly maxAttempts = 5;
|
||||
|
||||
constructor(
|
||||
@Inject(SMS_VERIFICATION_REPOSITORY)
|
||||
private readonly smsVerificationRepository: SmsVerificationRepository,
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
|
||||
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.codeExpireSeconds = this.configService.get<number>('SMS_CODE_EXPIRE_SECONDS', 300);
|
||||
this.dailyLimit = this.configService.get<number>('SMS_DAILY_LIMIT', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
async sendCode(dto: SendSmsDto): Promise<{ expiresIn: number }> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
|
||||
// 检查每日发送限制
|
||||
const dailyCount = await this.smsVerificationRepository.getDailySendCount(phone);
|
||||
if (dailyCount >= this.dailyLimit) {
|
||||
throw new BadRequestException('今日发送次数已达上限,请明天再试');
|
||||
}
|
||||
|
||||
// 根据类型进行额外验证
|
||||
await this.validateSendRequest(phone, dto.type);
|
||||
|
||||
// 生成验证码
|
||||
const code = SmsCode.generate();
|
||||
const expiresAt = new Date(Date.now() + this.codeExpireSeconds * 1000);
|
||||
|
||||
// 保存验证码
|
||||
await this.smsVerificationRepository.create({
|
||||
phone,
|
||||
code: code.value,
|
||||
type: dto.type,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// TODO: 调用阿里云短信 API 发送
|
||||
await this.sendSmsToProvider(phone.value, code.value, dto.type);
|
||||
|
||||
return { expiresIn: this.codeExpireSeconds };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
*/
|
||||
async verifyCode(dto: VerifySmsDto): Promise<boolean> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
|
||||
const verification = await this.smsVerificationRepository.findLatestValid(phone, dto.type);
|
||||
if (!verification) {
|
||||
throw new BadRequestException('验证码已过期或不存在');
|
||||
}
|
||||
|
||||
if (verification.attempts >= this.maxAttempts) {
|
||||
throw new BadRequestException('验证码尝试次数过多,请重新获取');
|
||||
}
|
||||
|
||||
if (verification.code !== dto.code) {
|
||||
await this.smsVerificationRepository.incrementAttempts(verification.id);
|
||||
throw new BadRequestException('验证码错误');
|
||||
}
|
||||
|
||||
await this.smsVerificationRepository.markAsVerified(verification.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据发送类型进行验证
|
||||
*/
|
||||
private async validateSendRequest(phone: Phone, type: SmsVerificationType): Promise<void> {
|
||||
const user = await this.userRepository.findByPhone(phone);
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||
|
||||
switch (type) {
|
||||
case SmsVerificationType.REGISTER:
|
||||
// 注册:手机号不能已存在
|
||||
if (user) {
|
||||
throw new BadRequestException('手机号已注册');
|
||||
}
|
||||
if (legacyUser && !legacyUser.migratedToV2) {
|
||||
throw new BadRequestException('此手机号已在1.0系统注册,请直接登录');
|
||||
}
|
||||
break;
|
||||
|
||||
case SmsVerificationType.LOGIN:
|
||||
case SmsVerificationType.RESET_PASSWORD:
|
||||
// 登录/重置密码:手机号必须已存在
|
||||
if (!user && !(legacyUser && !legacyUser.migratedToV2)) {
|
||||
throw new BadRequestException('手机号未注册');
|
||||
}
|
||||
break;
|
||||
|
||||
case SmsVerificationType.CHANGE_PHONE:
|
||||
// 更换手机号:新手机号不能已存在
|
||||
if (user || (legacyUser && !legacyUser.migratedToV2)) {
|
||||
throw new BadRequestException('手机号已被使用');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用短信服务商发送(阿里云)
|
||||
*/
|
||||
private async sendSmsToProvider(
|
||||
phone: string,
|
||||
code: string,
|
||||
type: SmsVerificationType,
|
||||
): Promise<void> {
|
||||
// TODO: 实现阿里云短信发送
|
||||
// const accessKeyId = this.configService.get<string>('SMS_ACCESS_KEY_ID');
|
||||
// const accessKeySecret = this.configService.get<string>('SMS_ACCESS_KEY_SECRET');
|
||||
// const signName = this.configService.get<string>('SMS_SIGN_NAME');
|
||||
// const templateCode = this.configService.get<string>('SMS_TEMPLATE_CODE');
|
||||
|
||||
// 开发环境打印验证码
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
console.log(`[SMS] Phone: ${phone}, Code: ${code}, Type: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
AccountSequence,
|
||||
Phone,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
} from '@/domain';
|
||||
|
||||
export interface UserProfileResult {
|
||||
accountSequence: string;
|
||||
phone: string;
|
||||
source: 'V1' | 'V2';
|
||||
status: string;
|
||||
kycStatus: string;
|
||||
realName?: string;
|
||||
createdAt?: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async getProfile(accountSequence: string): Promise<UserProfileResult> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
accountSequence: user.accountSequence.value,
|
||||
phone: user.phone.masked,
|
||||
source: user.source,
|
||||
status: user.status,
|
||||
kycStatus: user.kycStatus,
|
||||
realName: user.isKycVerified ? this.maskName(user.realName!) : undefined,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换手机号
|
||||
*/
|
||||
async changePhone(accountSequence: string, newPhone: string): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
const phone = Phone.create(newPhone);
|
||||
|
||||
// 检查新手机号是否已被使用
|
||||
const exists = await this.userRepository.existsByPhone(phone);
|
||||
if (exists) {
|
||||
throw new Error('手机号已被使用');
|
||||
}
|
||||
|
||||
user.changePhone(phone);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏姓名
|
||||
*/
|
||||
private maskName(name: string): string {
|
||||
if (name.length <= 1) return name;
|
||||
return name[0] + '*'.repeat(name.length - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './user.aggregate';
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
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;
|
||||
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 _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._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 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录成功
|
||||
*/
|
||||
recordLoginSuccess(ip?: string): void {
|
||||
this._loginFailCount = 0;
|
||||
this._lockedUntil = undefined;
|
||||
this._lastLoginAt = new Date();
|
||||
this._lastLoginIp = ip;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败
|
||||
*/
|
||||
recordLoginFailure(maxAttempts: number = 5, lockMinutes: number = 30): void {
|
||||
this._loginFailCount += 1;
|
||||
if (this._loginFailCount >= maxAttempts) {
|
||||
this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000);
|
||||
}
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁账户
|
||||
*/
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './user-registered.event';
|
||||
export * from './user-kyc-verified.event';
|
||||
export * from './legacy-user-migrated.event';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* 1.0 用户迁移事件
|
||||
*/
|
||||
export class LegacyUserMigratedEvent {
|
||||
static readonly EVENT_TYPE = 'user.legacy.migrated';
|
||||
|
||||
constructor(
|
||||
public readonly accountSequence: string,
|
||||
public readonly phone: string,
|
||||
public readonly migratedAt: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, unknown> {
|
||||
return {
|
||||
eventType: LegacyUserMigratedEvent.EVENT_TYPE,
|
||||
accountSequence: this.accountSequence,
|
||||
phone: this.phone,
|
||||
migratedAt: this.migratedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* 用户 KYC 认证通过事件
|
||||
*/
|
||||
export class UserKycVerifiedEvent {
|
||||
static readonly EVENT_TYPE = 'user.kyc.verified';
|
||||
|
||||
constructor(
|
||||
public readonly accountSequence: string,
|
||||
public readonly realName: string,
|
||||
public readonly verifiedAt: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, unknown> {
|
||||
return {
|
||||
eventType: UserKycVerifiedEvent.EVENT_TYPE,
|
||||
accountSequence: this.accountSequence,
|
||||
realName: this.realName,
|
||||
verifiedAt: this.verifiedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* 用户注册事件
|
||||
*/
|
||||
export class UserRegisteredEvent {
|
||||
static readonly EVENT_TYPE = 'user.registered';
|
||||
|
||||
constructor(
|
||||
public readonly accountSequence: string,
|
||||
public readonly phone: string,
|
||||
public readonly source: 'V1' | 'V2',
|
||||
public readonly registeredAt: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, unknown> {
|
||||
return {
|
||||
eventType: UserRegisteredEvent.EVENT_TYPE,
|
||||
accountSequence: this.accountSequence,
|
||||
phone: this.phone,
|
||||
source: this.source,
|
||||
registeredAt: this.registeredAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './value-objects';
|
||||
export * from './aggregates';
|
||||
export * from './services';
|
||||
export * from './repositories';
|
||||
export * from './events';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './user.repository.interface';
|
||||
export * from './synced-legacy-user.repository.interface';
|
||||
export * from './refresh-token.repository.interface';
|
||||
export * from './sms-verification.repository.interface';
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
export const REFRESH_TOKEN_REPOSITORY = Symbol('REFRESH_TOKEN_REPOSITORY');
|
||||
|
||||
export interface RefreshTokenData {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
token: string;
|
||||
deviceInfo?: string;
|
||||
ipAddress?: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
revokedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌仓储接口
|
||||
*/
|
||||
export interface RefreshTokenRepository {
|
||||
/**
|
||||
* 根据 token 查找
|
||||
*/
|
||||
findByToken(token: string): Promise<RefreshTokenData | null>;
|
||||
|
||||
/**
|
||||
* 创建刷新令牌
|
||||
*/
|
||||
create(data: {
|
||||
userId: bigint;
|
||||
token: string;
|
||||
deviceInfo?: string;
|
||||
ipAddress?: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<RefreshTokenData>;
|
||||
|
||||
/**
|
||||
* 撤销令牌
|
||||
*/
|
||||
revoke(token: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 撤销用户的所有令牌
|
||||
*/
|
||||
revokeAllByUserId(userId: bigint): Promise<void>;
|
||||
|
||||
/**
|
||||
* 删除过期令牌
|
||||
*/
|
||||
deleteExpired(): Promise<number>;
|
||||
|
||||
/**
|
||||
* 获取用户的活跃令牌数量
|
||||
*/
|
||||
countActiveByUserId(userId: bigint): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Phone } from '../value-objects';
|
||||
|
||||
export const SMS_VERIFICATION_REPOSITORY = Symbol('SMS_VERIFICATION_REPOSITORY');
|
||||
|
||||
export enum SmsVerificationType {
|
||||
REGISTER = 'REGISTER',
|
||||
LOGIN = 'LOGIN',
|
||||
RESET_PASSWORD = 'RESET_PASSWORD',
|
||||
CHANGE_PHONE = 'CHANGE_PHONE',
|
||||
}
|
||||
|
||||
export interface SmsVerificationData {
|
||||
id: bigint;
|
||||
phone: Phone;
|
||||
code: string;
|
||||
type: SmsVerificationType;
|
||||
expiresAt: Date;
|
||||
verifiedAt?: Date;
|
||||
attempts: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码仓储接口
|
||||
*/
|
||||
export interface SmsVerificationRepository {
|
||||
/**
|
||||
* 创建验证码
|
||||
*/
|
||||
create(data: {
|
||||
phone: Phone;
|
||||
code: string;
|
||||
type: SmsVerificationType;
|
||||
expiresAt: Date;
|
||||
}): Promise<SmsVerificationData>;
|
||||
|
||||
/**
|
||||
* 查找最新的有效验证码
|
||||
*/
|
||||
findLatestValid(phone: Phone, type: SmsVerificationType): Promise<SmsVerificationData | null>;
|
||||
|
||||
/**
|
||||
* 增加尝试次数
|
||||
*/
|
||||
incrementAttempts(id: bigint): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记为已验证
|
||||
*/
|
||||
markAsVerified(id: bigint): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取今日发送次数
|
||||
*/
|
||||
getDailySendCount(phone: Phone): Promise<number>;
|
||||
|
||||
/**
|
||||
* 删除过期验证码
|
||||
*/
|
||||
deleteExpired(): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { AccountSequence, Phone } from '../value-objects';
|
||||
|
||||
export const SYNCED_LEGACY_USER_REPOSITORY = Symbol('SYNCED_LEGACY_USER_REPOSITORY');
|
||||
|
||||
/**
|
||||
* 同步的 1.0 用户数据(只读)
|
||||
*/
|
||||
export interface SyncedLegacyUserData {
|
||||
id: bigint;
|
||||
legacyId: bigint;
|
||||
accountSequence: AccountSequence;
|
||||
phone: Phone;
|
||||
passwordHash: string;
|
||||
status: string;
|
||||
legacyCreatedAt: Date;
|
||||
migratedToV2: boolean;
|
||||
migratedAt?: Date;
|
||||
syncedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步的 1.0 用户仓储接口
|
||||
*/
|
||||
export interface SyncedLegacyUserRepository {
|
||||
/**
|
||||
* 根据手机号查找同步的 1.0 用户
|
||||
*/
|
||||
findByPhone(phone: Phone): Promise<SyncedLegacyUserData | null>;
|
||||
|
||||
/**
|
||||
* 根据账户序列号查找同步的 1.0 用户
|
||||
*/
|
||||
findByAccountSequence(accountSequence: AccountSequence): Promise<SyncedLegacyUserData | null>;
|
||||
|
||||
/**
|
||||
* 标记用户已迁移到 V2
|
||||
*/
|
||||
markAsMigrated(accountSequence: AccountSequence): Promise<void>;
|
||||
|
||||
/**
|
||||
* 检查用户是否已迁移
|
||||
*/
|
||||
isMigrated(accountSequence: AccountSequence): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { UserAggregate } from '../aggregates';
|
||||
import { AccountSequence, Phone } from '../value-objects';
|
||||
|
||||
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
|
||||
|
||||
/**
|
||||
* 用户仓储接口
|
||||
*/
|
||||
export interface UserRepository {
|
||||
/**
|
||||
* 根据 ID 查找用户
|
||||
*/
|
||||
findById(id: bigint): Promise<UserAggregate | null>;
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户
|
||||
*/
|
||||
findByPhone(phone: Phone): Promise<UserAggregate | null>;
|
||||
|
||||
/**
|
||||
* 根据账户序列号查找用户
|
||||
*/
|
||||
findByAccountSequence(accountSequence: AccountSequence): Promise<UserAggregate | null>;
|
||||
|
||||
/**
|
||||
* 保存用户(创建或更新)
|
||||
*/
|
||||
save(user: UserAggregate): Promise<UserAggregate>;
|
||||
|
||||
/**
|
||||
* 检查手机号是否已存在
|
||||
*/
|
||||
existsByPhone(phone: Phone): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查账户序列号是否已存在
|
||||
*/
|
||||
existsByAccountSequence(accountSequence: AccountSequence): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取下一个账户序列号
|
||||
*/
|
||||
getNextAccountSequence(): Promise<AccountSequence>;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { AccountSequence } from '../value-objects';
|
||||
|
||||
/**
|
||||
* 账户序列号生成器领域服务
|
||||
* 负责生成 V2 格式的账户序列号
|
||||
*/
|
||||
export abstract class AccountSequenceGenerator {
|
||||
/**
|
||||
* 生成下一个 V2 格式的账户序列号
|
||||
*/
|
||||
abstract generateNext(): Promise<AccountSequence>;
|
||||
|
||||
/**
|
||||
* 获取当天已使用的序号数量
|
||||
*/
|
||||
abstract getDailyCount(date: Date): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './account-sequence-generator.service';
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* 账户序列号值对象
|
||||
*
|
||||
* V1 格式 (1.0系统): D + 年(2位) + 月(2位) + 日(2位) + 5位序号 = 12字符
|
||||
* 示例: D2512110008 -> 2025年12月11日的第8个注册用户
|
||||
*
|
||||
* V2 格式 (2.0系统): D + 年(2位) + 月(2位) + 日(2位) + 8位序号 = 15字符
|
||||
* 示例: D25121100000008 -> 2025年12月11日的第8个注册用户
|
||||
*/
|
||||
export class AccountSequence {
|
||||
private static readonly V1_PATTERN = /^D\d{11}$/; // 12 字符
|
||||
private static readonly V2_PATTERN = /^D\d{14}$/; // 15 字符
|
||||
private static readonly V1_LENGTH = 12;
|
||||
private static readonly V2_LENGTH = 15;
|
||||
private static readonly V2_SEQ_LENGTH = 8;
|
||||
private static readonly V2_MAX_DAILY_SEQ = 99999999;
|
||||
|
||||
constructor(public readonly value: string) {
|
||||
if (!this.isValid(value)) {
|
||||
throw new Error(
|
||||
`账户序列号格式无效: ${value},应为 D + 年月日(6位) + 序号(5位或8位)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isValid(value: string): boolean {
|
||||
return AccountSequence.V1_PATTERN.test(value) || AccountSequence.V2_PATTERN.test(value);
|
||||
}
|
||||
|
||||
static create(value: string): AccountSequence {
|
||||
return new AccountSequence(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 V2 格式的账户序列号
|
||||
* @param date 日期
|
||||
* @param dailySequence 当日序号 (0-99999999)
|
||||
*/
|
||||
static generateV2(date: Date, dailySequence: number): AccountSequence {
|
||||
if (dailySequence < 0 || dailySequence > AccountSequence.V2_MAX_DAILY_SEQ) {
|
||||
throw new Error(`当日序号超出范围: ${dailySequence},应为 0-${AccountSequence.V2_MAX_DAILY_SEQ}`);
|
||||
}
|
||||
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(AccountSequence.V2_SEQ_LENGTH, '0');
|
||||
return new AccountSequence(`D${year}${month}${day}${seq}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V1 格式(来自 1.0 系统)
|
||||
*/
|
||||
get isV1(): boolean {
|
||||
return this.value.length === AccountSequence.V1_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V2 格式(来自 2.0 系统)
|
||||
*/
|
||||
get isV2(): boolean {
|
||||
return this.value.length === AccountSequence.V2_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户来源
|
||||
*/
|
||||
get source(): 'V1' | 'V2' {
|
||||
return this.isV1 ? 'V1' : 'V2';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取日期字符串 (YYMMDD)
|
||||
*/
|
||||
get dateString(): string {
|
||||
return this.value.slice(1, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列号中提取当日序号
|
||||
*/
|
||||
get dailySequence(): number {
|
||||
return parseInt(this.value.slice(7), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册日期
|
||||
*/
|
||||
get registrationDate(): Date {
|
||||
const dateStr = this.dateString;
|
||||
const year = 2000 + parseInt(dateStr.slice(0, 2), 10);
|
||||
const month = parseInt(dateStr.slice(2, 4), 10) - 1;
|
||||
const day = parseInt(dateStr.slice(4, 6), 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
equals(other: AccountSequence): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './account-sequence.vo';
|
||||
export * from './phone.vo';
|
||||
export * from './password.vo';
|
||||
export * from './sms-code.vo';
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* 密码值对象
|
||||
* 包含密码验证规则和加密逻辑
|
||||
*/
|
||||
export class Password {
|
||||
private static readonly MIN_LENGTH = 8;
|
||||
private static readonly MAX_LENGTH = 32;
|
||||
private static readonly SALT_ROUNDS = 12;
|
||||
|
||||
private constructor(public readonly hash: string) {}
|
||||
|
||||
/**
|
||||
* 从明文密码创建(会进行加密)
|
||||
*/
|
||||
static async create(plainPassword: string): Promise<Password> {
|
||||
Password.validatePlain(plainPassword);
|
||||
const hash = await bcrypt.hash(plainPassword, Password.SALT_ROUNDS);
|
||||
return new Password(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已加密的 hash 重建
|
||||
*/
|
||||
static fromHash(hash: string): Password {
|
||||
return new Password(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证明文密码是否匹配
|
||||
*/
|
||||
async verify(plainPassword: string): Promise<boolean> {
|
||||
return bcrypt.compare(plainPassword, this.hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码格式
|
||||
*/
|
||||
private static validatePlain(password: string): void {
|
||||
if (password.length < Password.MIN_LENGTH) {
|
||||
throw new Error(`密码长度不能少于 ${Password.MIN_LENGTH} 位`);
|
||||
}
|
||||
if (password.length > Password.MAX_LENGTH) {
|
||||
throw new Error(`密码长度不能超过 ${Password.MAX_LENGTH} 位`);
|
||||
}
|
||||
// 至少包含一个字母和一个数字
|
||||
if (!/[a-zA-Z]/.test(password) || !/\d/.test(password)) {
|
||||
throw new Error('密码必须包含字母和数字');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查密码强度(仅验证格式,不加密)
|
||||
*/
|
||||
static checkStrength(password: string): { valid: boolean; message?: string } {
|
||||
try {
|
||||
Password.validatePlain(password);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return '[PROTECTED]';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 手机号值对象
|
||||
* 中国大陆手机号格式验证
|
||||
*/
|
||||
export class Phone {
|
||||
private static readonly PATTERN = /^1[3-9]\d{9}$/;
|
||||
|
||||
constructor(public readonly value: string) {
|
||||
if (!this.isValid(value)) {
|
||||
throw new Error(`手机号格式无效: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
private isValid(value: string): boolean {
|
||||
return Phone.PATTERN.test(value);
|
||||
}
|
||||
|
||||
static create(value: string): Phone {
|
||||
return new Phone(value);
|
||||
}
|
||||
|
||||
static isValidFormat(value: string): boolean {
|
||||
return Phone.PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取脱敏后的手机号(前3后4)
|
||||
*/
|
||||
get masked(): string {
|
||||
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
equals(other: Phone): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 短信验证码值对象
|
||||
*/
|
||||
export class SmsCode {
|
||||
private static readonly LENGTH = 6;
|
||||
private static readonly PATTERN = /^\d{6}$/;
|
||||
|
||||
constructor(public readonly value: string) {
|
||||
if (!this.isValid(value)) {
|
||||
throw new Error(`验证码格式无效: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
private isValid(value: string): boolean {
|
||||
return SmsCode.PATTERN.test(value);
|
||||
}
|
||||
|
||||
static create(value: string): SmsCode {
|
||||
return new SmsCode(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机验证码
|
||||
*/
|
||||
static generate(): SmsCode {
|
||||
const code = Math.random()
|
||||
.toString()
|
||||
.slice(2, 2 + SmsCode.LENGTH)
|
||||
.padEnd(SmsCode.LENGTH, '0');
|
||||
return new SmsCode(code);
|
||||
}
|
||||
|
||||
equals(other: SmsCode): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './infrastructure.module';
|
||||
export * from './persistence/prisma/prisma.service';
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './persistence/prisma/prisma.module';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import {
|
||||
PrismaUserRepository,
|
||||
PrismaSyncedLegacyUserRepository,
|
||||
PrismaRefreshTokenRepository,
|
||||
PrismaSmsVerificationRepository,
|
||||
} from './persistence/repositories';
|
||||
import { LegacyUserCdcConsumer } from './messaging/cdc';
|
||||
import {
|
||||
USER_REPOSITORY,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
} from '@/domain';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
providers: [
|
||||
// CDC
|
||||
LegacyUserCdcConsumer,
|
||||
|
||||
// Repositories
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: PrismaUserRepository,
|
||||
},
|
||||
{
|
||||
provide: SYNCED_LEGACY_USER_REPOSITORY,
|
||||
useClass: PrismaSyncedLegacyUserRepository,
|
||||
},
|
||||
{
|
||||
provide: REFRESH_TOKEN_REPOSITORY,
|
||||
useClass: PrismaRefreshTokenRepository,
|
||||
},
|
||||
{
|
||||
provide: SMS_VERIFICATION_REPOSITORY,
|
||||
useClass: PrismaSmsVerificationRepository,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
USER_REPOSITORY,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './legacy-user-cdc.consumer';
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
interface CdcUserPayload {
|
||||
before: CdcUser | null;
|
||||
after: CdcUser | null;
|
||||
source: {
|
||||
sequence: string;
|
||||
};
|
||||
op: 'c' | 'u' | 'd' | 'r'; // create, update, delete, read (snapshot)
|
||||
}
|
||||
|
||||
interface CdcUser {
|
||||
id: number;
|
||||
phone: string;
|
||||
password_hash: string;
|
||||
account_sequence: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CDC Consumer - 消费 1.0 用户变更事件
|
||||
* 监听 Debezium 发送的 CDC 事件,同步到 synced_legacy_users 表
|
||||
*/
|
||||
@Injectable()
|
||||
export class LegacyUserCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(LegacyUserCdcConsumer.name);
|
||||
private kafka: Kafka;
|
||||
private consumer: Consumer;
|
||||
private isConnected = false;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
const brokers = this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(',');
|
||||
|
||||
this.kafka = new Kafka({
|
||||
clientId: 'auth-service-cdc',
|
||||
brokers,
|
||||
});
|
||||
|
||||
this.consumer = this.kafka.consumer({
|
||||
groupId: this.configService.get<string>('CDC_CONSUMER_GROUP', 'auth-service-cdc-group'),
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
// 开发环境可选择不启动 CDC
|
||||
if (this.configService.get('CDC_ENABLED', 'true') !== 'true') {
|
||||
this.logger.log('CDC Consumer is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
|
||||
const topic = this.configService.get<string>('CDC_TOPIC_USERS', 'dbserver1.public.users');
|
||||
await this.consumer.subscribe({ topic, fromBeginning: true });
|
||||
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload) => {
|
||||
await this.handleMessage(payload);
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`CDC Consumer started, listening to topic: ${topic}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start CDC Consumer', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.isConnected) {
|
||||
await this.consumer.disconnect();
|
||||
this.logger.log('CDC Consumer disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(payload: EachMessagePayload) {
|
||||
const { topic, partition, message } = payload;
|
||||
|
||||
if (!message.value) return;
|
||||
|
||||
try {
|
||||
const cdcEvent: CdcUserPayload = JSON.parse(message.value.toString());
|
||||
await this.processCdcEvent(cdcEvent);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process CDC message from ${topic}[${partition}]`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processCdcEvent(event: CdcUserPayload) {
|
||||
const { before, after, source, op } = event;
|
||||
|
||||
switch (op) {
|
||||
case 'c': // Create
|
||||
case 'r': // Read (snapshot)
|
||||
if (after) {
|
||||
await this.upsertLegacyUser(after, BigInt(source.sequence));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'u': // Update
|
||||
if (after) {
|
||||
await this.upsertLegacyUser(after, BigInt(source.sequence));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'd': // Delete
|
||||
if (before) {
|
||||
await this.deleteLegacyUser(before.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertLegacyUser(user: CdcUser, sequenceNum: bigint) {
|
||||
try {
|
||||
await this.prisma.syncedLegacyUser.upsert({
|
||||
where: { legacyId: BigInt(user.id) },
|
||||
update: {
|
||||
phone: user.phone,
|
||||
passwordHash: user.password_hash,
|
||||
accountSequence: user.account_sequence,
|
||||
status: user.status,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
legacyId: BigInt(user.id),
|
||||
phone: user.phone,
|
||||
passwordHash: user.password_hash,
|
||||
accountSequence: user.account_sequence,
|
||||
status: user.status,
|
||||
legacyCreatedAt: new Date(user.created_at),
|
||||
sourceSequenceNum: sequenceNum,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`Synced legacy user: ${user.account_sequence}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to upsert legacy user ${user.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteLegacyUser(legacyId: number) {
|
||||
try {
|
||||
// 不实际删除,只标记状态
|
||||
await this.prisma.syncedLegacyUser.update({
|
||||
where: { legacyId: BigInt(legacyId) },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
|
||||
this.logger.debug(`Marked legacy user as deleted: ${legacyId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to mark legacy user as deleted: ${legacyId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
super({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './user.repository';
|
||||
export * from './synced-legacy-user.repository';
|
||||
export * from './refresh-token.repository';
|
||||
export * from './sms-verification.repository';
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RefreshTokenRepository, RefreshTokenData } from '@/domain';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRefreshTokenRepository implements RefreshTokenRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByToken(token: string): Promise<RefreshTokenData | null> {
|
||||
const data = await this.prisma.refreshToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
return this.toDomain(data);
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
userId: bigint;
|
||||
token: string;
|
||||
deviceInfo?: string;
|
||||
ipAddress?: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<RefreshTokenData> {
|
||||
const created = await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
token: data.token,
|
||||
deviceInfo: data.deviceInfo,
|
||||
ipAddress: data.ipAddress,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toDomain(created);
|
||||
}
|
||||
|
||||
async revoke(token: string): Promise<void> {
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { token },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAllByUserId(userId: bigint): Promise<void> {
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async countActiveByUserId(userId: bigint): Promise<number> {
|
||||
return this.prisma.refreshToken.count({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(data: any): RefreshTokenData {
|
||||
return {
|
||||
id: data.id,
|
||||
userId: data.userId,
|
||||
token: data.token,
|
||||
deviceInfo: data.deviceInfo,
|
||||
ipAddress: data.ipAddress,
|
||||
expiresAt: data.expiresAt,
|
||||
createdAt: data.createdAt,
|
||||
revokedAt: data.revokedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
SmsVerificationRepository,
|
||||
SmsVerificationData,
|
||||
SmsVerificationType,
|
||||
Phone,
|
||||
} from '@/domain';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaSmsVerificationRepository implements SmsVerificationRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(data: {
|
||||
phone: Phone;
|
||||
code: string;
|
||||
type: SmsVerificationType;
|
||||
expiresAt: Date;
|
||||
}): Promise<SmsVerificationData> {
|
||||
const created = await this.prisma.smsVerification.create({
|
||||
data: {
|
||||
phone: data.phone.value,
|
||||
code: data.code,
|
||||
type: data.type,
|
||||
expiresAt: data.expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toDomain(created);
|
||||
}
|
||||
|
||||
async findLatestValid(phone: Phone, type: SmsVerificationType): Promise<SmsVerificationData | null> {
|
||||
const data = await this.prisma.smsVerification.findFirst({
|
||||
where: {
|
||||
phone: phone.value,
|
||||
type,
|
||||
expiresAt: { gt: new Date() },
|
||||
verifiedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
return this.toDomain(data);
|
||||
}
|
||||
|
||||
async incrementAttempts(id: bigint): Promise<void> {
|
||||
await this.prisma.smsVerification.update({
|
||||
where: { id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async markAsVerified(id: bigint): Promise<void> {
|
||||
await this.prisma.smsVerification.update({
|
||||
where: { id },
|
||||
data: { verifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async getDailySendCount(phone: Phone): Promise<number> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return this.prisma.smsVerification.count({
|
||||
where: {
|
||||
phone: phone.value,
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.prisma.smsVerification.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
private toDomain(data: any): SmsVerificationData {
|
||||
return {
|
||||
id: data.id,
|
||||
phone: Phone.create(data.phone),
|
||||
code: data.code,
|
||||
type: data.type as SmsVerificationType,
|
||||
expiresAt: data.expiresAt,
|
||||
verifiedAt: data.verifiedAt,
|
||||
attempts: data.attempts,
|
||||
createdAt: data.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
SyncedLegacyUserRepository,
|
||||
SyncedLegacyUserData,
|
||||
AccountSequence,
|
||||
Phone,
|
||||
} from '@/domain';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaSyncedLegacyUserRepository implements SyncedLegacyUserRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByPhone(phone: Phone): Promise<SyncedLegacyUserData | null> {
|
||||
const user = await this.prisma.syncedLegacyUser.findFirst({
|
||||
where: { phone: phone.value },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return this.toDomain(user);
|
||||
}
|
||||
|
||||
async findByAccountSequence(accountSequence: AccountSequence): Promise<SyncedLegacyUserData | null> {
|
||||
const user = await this.prisma.syncedLegacyUser.findUnique({
|
||||
where: { accountSequence: accountSequence.value },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return this.toDomain(user);
|
||||
}
|
||||
|
||||
async markAsMigrated(accountSequence: AccountSequence): Promise<void> {
|
||||
await this.prisma.syncedLegacyUser.update({
|
||||
where: { accountSequence: accountSequence.value },
|
||||
data: {
|
||||
migratedToV2: true,
|
||||
migratedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isMigrated(accountSequence: AccountSequence): Promise<boolean> {
|
||||
const user = await this.prisma.syncedLegacyUser.findUnique({
|
||||
where: { accountSequence: accountSequence.value },
|
||||
select: { migratedToV2: true },
|
||||
});
|
||||
|
||||
return user?.migratedToV2 ?? false;
|
||||
}
|
||||
|
||||
private toDomain(user: any): SyncedLegacyUserData {
|
||||
return {
|
||||
id: user.id,
|
||||
legacyId: user.legacyId,
|
||||
accountSequence: AccountSequence.create(user.accountSequence),
|
||||
phone: Phone.create(user.phone),
|
||||
passwordHash: user.passwordHash,
|
||||
status: user.status,
|
||||
legacyCreatedAt: user.legacyCreatedAt,
|
||||
migratedToV2: user.migratedToV2,
|
||||
migratedAt: user.migratedAt,
|
||||
syncedAt: user.syncedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
UserRepository,
|
||||
UserAggregate,
|
||||
UserStatus,
|
||||
KycStatus,
|
||||
AccountSequence,
|
||||
Phone,
|
||||
} from '@/domain';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaUserRepository implements UserRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: bigint): Promise<UserAggregate | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return this.toDomain(user);
|
||||
}
|
||||
|
||||
async findByPhone(phone: Phone): Promise<UserAggregate | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { phone: phone.value },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return this.toDomain(user);
|
||||
}
|
||||
|
||||
async findByAccountSequence(accountSequence: AccountSequence): Promise<UserAggregate | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { accountSequence: accountSequence.value },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return this.toDomain(user);
|
||||
}
|
||||
|
||||
async save(user: UserAggregate): Promise<UserAggregate> {
|
||||
const snapshot = user.toSnapshot();
|
||||
const data = {
|
||||
phone: snapshot.phone.value,
|
||||
passwordHash: snapshot.passwordHash,
|
||||
accountSequence: snapshot.accountSequence.value,
|
||||
status: snapshot.status,
|
||||
kycStatus: snapshot.kycStatus,
|
||||
realName: snapshot.realName,
|
||||
idCardNo: snapshot.idCardNo,
|
||||
idCardFront: snapshot.idCardFront,
|
||||
idCardBack: snapshot.idCardBack,
|
||||
kycSubmittedAt: snapshot.kycSubmittedAt,
|
||||
kycVerifiedAt: snapshot.kycVerifiedAt,
|
||||
kycRejectReason: snapshot.kycRejectReason,
|
||||
loginFailCount: snapshot.loginFailCount,
|
||||
lockedUntil: snapshot.lockedUntil,
|
||||
lastLoginAt: snapshot.lastLoginAt,
|
||||
lastLoginIp: snapshot.lastLoginIp,
|
||||
};
|
||||
|
||||
if (snapshot.id) {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: snapshot.id },
|
||||
data,
|
||||
});
|
||||
return this.toDomain(updated);
|
||||
} else {
|
||||
const created = await this.prisma.user.create({
|
||||
data,
|
||||
});
|
||||
return this.toDomain(created);
|
||||
}
|
||||
}
|
||||
|
||||
async existsByPhone(phone: Phone): Promise<boolean> {
|
||||
const count = await this.prisma.user.count({
|
||||
where: { phone: phone.value },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsByAccountSequence(accountSequence: AccountSequence): Promise<boolean> {
|
||||
const count = await this.prisma.user.count({
|
||||
where: { accountSequence: accountSequence.value },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async getNextAccountSequence(): Promise<AccountSequence> {
|
||||
const now = new Date();
|
||||
const dateKey = this.getDateKey(now);
|
||||
|
||||
// 使用事务保证原子性
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// 更新或创建计数器
|
||||
const counter = await tx.dailySequenceCounter.upsert({
|
||||
where: { dateKey },
|
||||
update: { lastSeq: { increment: 1 } },
|
||||
create: { dateKey, lastSeq: 1 },
|
||||
});
|
||||
|
||||
return counter.lastSeq;
|
||||
});
|
||||
|
||||
return AccountSequence.generateV2(now, result);
|
||||
}
|
||||
|
||||
private getDateKey(date: Date): string {
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
private toDomain(user: any): UserAggregate {
|
||||
return UserAggregate.reconstitute({
|
||||
id: user.id,
|
||||
phone: Phone.create(user.phone),
|
||||
passwordHash: user.passwordHash,
|
||||
accountSequence: AccountSequence.create(user.accountSequence),
|
||||
status: user.status as UserStatus,
|
||||
kycStatus: user.kycStatus as KycStatus,
|
||||
realName: user.realName,
|
||||
idCardNo: user.idCardNo,
|
||||
idCardFront: user.idCardFront,
|
||||
idCardBack: user.idCardBack,
|
||||
kycSubmittedAt: user.kycSubmittedAt,
|
||||
kycVerifiedAt: user.kycVerifiedAt,
|
||||
kycRejectReason: user.kycRejectReason,
|
||||
loginFailCount: user.loginFailCount,
|
||||
lockedUntil: user.lockedUntil,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
lastLoginIp: user.lastLoginIp,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 全局前缀
|
||||
app.setGlobalPrefix('api/v2');
|
||||
|
||||
// 全局管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 全局过滤器
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
// 全局拦截器
|
||||
app.useGlobalInterceptors(
|
||||
new LoggingInterceptor(),
|
||||
new TransformInterceptor(),
|
||||
);
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || '*',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3024;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`🚀 Auth Service is running on port ${port}`);
|
||||
logger.log(`📚 API prefix: /api/v2`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
accountSequence: string;
|
||||
phone: string;
|
||||
source: 'V1' | 'V2';
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as CurrentUserPayload;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = '服务器内部错误';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
message = (exceptionResponse as any).message || message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: status,
|
||||
message,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('未提供访问令牌');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
|
||||
// 将用户信息附加到请求对象
|
||||
request.user = {
|
||||
accountSequence: payload.sub,
|
||||
phone: payload.phone,
|
||||
source: payload.source,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('访问令牌无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './guards/jwt-auth.guard';
|
||||
export * from './decorators/current-user.decorator';
|
||||
export * from './filters/http-exception.filter';
|
||||
export * from './interceptors/transform.interceptor';
|
||||
export * from './interceptors/logging.interceptor';
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, url, ip } = request;
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const { statusCode } = response;
|
||||
const delay = Date.now() - now;
|
||||
|
||||
this.logger.log(
|
||||
`${method} ${url} ${statusCode} - ${delay}ms - ${ip} - ${userAgent}`,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
const delay = Date.now() - now;
|
||||
|
||||
this.logger.error(
|
||||
`${method} ${url} ERROR - ${delay}ms - ${ip} - ${error.message}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
// 如果返回值已经有 success 字段,直接返回
|
||||
if (data && typeof data === 'object' && 'success' in data) {
|
||||
return {
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
# mining -> mining-service
|
||||
# trading -> trading-service
|
||||
# admin -> mining-admin-service
|
||||
# auth -> auth-service
|
||||
#
|
||||
|
||||
set -e
|
||||
|
|
@ -55,6 +56,7 @@ MINING_SERVICES=(
|
|||
"mining-service"
|
||||
"trading-service"
|
||||
"mining-admin-service"
|
||||
"auth-service"
|
||||
)
|
||||
|
||||
# Service Aliases
|
||||
|
|
@ -64,6 +66,7 @@ declare -A SERVICE_ALIASES=(
|
|||
["mining"]="mining-service"
|
||||
["trading"]="trading-service"
|
||||
["admin"]="mining-admin-service"
|
||||
["auth"]="auth-service"
|
||||
)
|
||||
|
||||
# 2.0 Databases
|
||||
|
|
@ -72,6 +75,7 @@ MINING_DATABASES=(
|
|||
"rwa_mining"
|
||||
"rwa_trading"
|
||||
"rwa_mining_admin"
|
||||
"rwa_auth"
|
||||
)
|
||||
|
||||
# Service to Database mapping
|
||||
|
|
@ -80,6 +84,7 @@ declare -A SERVICE_DB=(
|
|||
["mining-service"]="rwa_mining"
|
||||
["trading-service"]="rwa_trading"
|
||||
["mining-admin-service"]="rwa_mining_admin"
|
||||
["auth-service"]="rwa_auth"
|
||||
)
|
||||
|
||||
# 2.0 Ports
|
||||
|
|
@ -88,6 +93,7 @@ declare -A SERVICE_PORTS=(
|
|||
["mining-service"]="3021"
|
||||
["trading-service"]="3022"
|
||||
["mining-admin-service"]="3023"
|
||||
["auth-service"]="3024"
|
||||
)
|
||||
|
||||
# CDC Consumer Group
|
||||
|
|
@ -864,6 +870,7 @@ show_help() {
|
|||
echo " mining -> mining-service"
|
||||
echo " trading -> trading-service"
|
||||
echo " admin -> mining-admin-service"
|
||||
echo " auth -> auth-service"
|
||||
echo ""
|
||||
echo -e "${BOLD}Examples:${NC}"
|
||||
echo " $0 up # Start all services"
|
||||
|
|
|
|||
Loading…
Reference in New Issue