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:
hailin 2026-01-10 18:50:59 -08:00
parent c8c2e63da6
commit f7278b6196
62 changed files with 13963 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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
}

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -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' };
}
}
}

View File

@ -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';

View File

@ -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 };
}
}

View File

@ -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 };
}
}

View File

@ -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 } };
}
}

View File

@ -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 };
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -0,0 +1,2 @@
export * from './application.module';
export * from './services';

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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);
}
/**
* 44
*/
private maskIdCard(idCard: string): string {
if (idCard.length !== 18) return idCard;
return idCard.slice(0, 4) + '*'.repeat(10) + idCard.slice(-4);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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}`);
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
export * from './user.aggregate';

View File

@ -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,
};
}
}

View File

@ -0,0 +1,3 @@
export * from './user-registered.event';
export * from './user-kyc-verified.event';
export * from './legacy-user-migrated.event';

View File

@ -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(),
};
}
}

View File

@ -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(),
};
}
}

View File

@ -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(),
};
}
}

View File

@ -0,0 +1,5 @@
export * from './value-objects';
export * from './aggregates';
export * from './services';
export * from './repositories';
export * from './events';

View File

@ -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';

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -0,0 +1 @@
export * from './account-sequence-generator.service';

View File

@ -0,0 +1,103 @@
/**
*
*
* V1 (1.0): D + (2) + (2) + (2) + 5 = 12
* 示例: D2512110008 -> 202512118
*
* V2 (2.0): D + (2) + (2) + (2) + 8 = 15
* 示例: D25121100000008 -> 202512118
*/
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;
}
}

View File

@ -0,0 +1,4 @@
export * from './account-sequence.vo';
export * from './phone.vo';
export * from './password.vo';
export * from './sms-code.vo';

View File

@ -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]';
}
}

View File

@ -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);
}
/**
* 34
*/
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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,2 @@
export * from './infrastructure.module';
export * from './persistence/prisma/prisma.service';

View File

@ -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 {}

View File

@ -0,0 +1 @@
export * from './legacy-user-cdc.consumer';

View File

@ -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);
}
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -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();
}
}

View File

@ -0,0 +1,4 @@
export * from './user.repository';
export * from './synced-legacy-user.repository';
export * from './refresh-token.repository';
export * from './sms-verification.repository';

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
});
}
}

View File

@ -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();

View File

@ -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;
},
);

View File

@ -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(),
});
}
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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}`,
);
},
}),
);
}
}

View File

@ -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(),
};
}),
);
}
}

View File

@ -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/*"]
}
}
}

View File

@ -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"