identity_service_context first commit

This commit is contained in:
hailin 2025-11-24 06:09:06 +00:00
parent b9a3fb9a83
commit c29c185a03
71 changed files with 5335 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# Application
NODE_ENV=development
PORT=3000
API_PREFIX=api/v1
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/identity_db?schema=public"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=your-super-secret-key-change-in-production
JWT_ACCESS_EXPIRATION=2h
JWT_REFRESH_EXPIRATION=30d
# Kafka
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=identity-service
KAFKA_GROUP_ID=identity-service-group
# SMS Provider (Aliyun)
SMS_PROVIDER=aliyun
SMS_ACCESS_KEY_ID=your-access-key-id
SMS_ACCESS_KEY_SECRET=your-access-key-secret
SMS_SIGN_NAME=RWA平台
SMS_TEMPLATE_CODE=SMS_123456789
# Blockchain RPC
KAVA_RPC_URL=https://kava-rpc.example.com
DST_RPC_URL=https://dst-rpc.example.com
BSC_RPC_URL=https://bsc-dataseed.binance.org
# Wallet Encryption
WALLET_ENCRYPTION_SALT=rwa-wallet-salt-change-in-production

View File

@ -0,0 +1,26 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

View File

@ -0,0 +1,43 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build
dist/
build/
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test
coverage/
.nyc_output
# Prisma
prisma/migrations/**/migration_lock.toml
# Misc
*.tgz
.cache

View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 80
}

View File

@ -0,0 +1,50 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy built assets
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package*.json ./
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs
# Set ownership
RUN chown -R nestjs:nodejs /app
USER nestjs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1
# Start
CMD ["npm", "run", "start:prod"]

View File

@ -0,0 +1,181 @@
# Identity Service
RWA平台用户身份管理微服务 - 基于NestJS + Prisma + Clean Architecture
## 技术栈
- **框架**: NestJS 10
- **ORM**: Prisma 5
- **数据库**: PostgreSQL 15
- **缓存**: Redis 7 (ioredis)
- **消息队列**: Kafka
- **区块链**: ethers.js 6
- **架构**: Clean Architecture / Hexagonal Architecture / DDD / CQRS
## 功能特性
- ✅ 自动创建账户首次打开APP
- ✅ 多设备支持最多5个设备同时登录
- ✅ 助记词生成与恢复
- ✅ 手机号绑定与恢复
- ✅ 三链钱包地址派生KAVA/DST/BSC
- ✅ JWT Token认证
- ✅ Token自动刷新账户永不过期
- ✅ Kafka事件发布
- ✅ 死信队列与自动重试
## 目录结构
```
src/
├── api/ # 表现层
│ ├── controllers/ # HTTP控制器
│ └── dto/ # 数据传输对象
├── application/ # 应用层
│ ├── commands/ # 命令处理器
│ ├── queries/ # 查询处理器
│ └── services/ # 应用服务
├── domain/ # 领域层
│ ├── aggregates/ # 聚合根
│ ├── entities/ # 实体
│ ├── value-objects/ # 值对象
│ ├── events/ # 领域事件
│ ├── repositories/ # 仓储接口
│ └── services/ # 领域服务
├── infrastructure/ # 基础设施层
│ ├── persistence/ # 数据持久化
│ ├── redis/ # Redis缓存
│ ├── kafka/ # Kafka消息
│ └── external/ # 外部服务
└── shared/ # 共享层
├── decorators/ # 装饰器
├── guards/ # 守卫
├── filters/ # 过滤器
└── exceptions/ # 异常
```
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑.env文件配置数据库连接等
```
### 3. 数据库迁移
```bash
# 生成Prisma客户端
npm run prisma:generate
# 运行迁移
npm run prisma:migrate
```
### 4. 启动服务
```bash
# 开发模式
npm run start:dev
# 生产模式
npm run build
npm run start:prod
```
## Docker部署
### 启动所有服务
```bash
docker-compose up -d
```
### 仅启动依赖服务
```bash
docker-compose up -d postgres redis kafka zookeeper
```
## API文档
启动服务后访问: http://localhost:3000/api/docs
## 主要API接口
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| POST | /api/v1/user/auto-create | 自动创建账户 | 否 |
| POST | /api/v1/user/recover-by-mnemonic | 助记词恢复 | 否 |
| POST | /api/v1/user/recover-by-phone | 手机号恢复 | 否 |
| POST | /api/v1/user/refresh-token | 刷新Token | 否 |
| POST | /api/v1/user/send-sms-code | 发送验证码 | 否 |
| POST | /api/v1/user/bind-phone | 绑定手机号 | 是 |
| GET | /api/v1/user/my-profile | 我的资料 | 是 |
| GET | /api/v1/user/my-devices | 我的设备 | 是 |
| DELETE | /api/v1/user/remove-device | 移除设备 | 是 |
| POST | /api/v1/user/logout | 退出登录 | 是 |
## Kafka Topics
| Topic | 描述 |
|-------|------|
| identity.user-account.created | 用户账户创建 |
| identity.device.added | 设备添加 |
| identity.device.removed | 设备移除 |
| identity.phone.bound | 手机号绑定 |
| identity.kyc.submitted | KYC提交 |
| identity.kyc.approved | KYC通过 |
| identity.kyc.rejected | KYC拒绝 |
| identity.account.frozen | 账户冻结 |
| identity.wallet.bound | 钱包绑定 |
## 测试
```bash
# 单元测试
npm run test
# E2E测试
npm run test:e2e
# 测试覆盖率
npm run test:cov
```
## 开发命令
```bash
# 格式化代码
npm run format
# Lint检查
npm run lint
# 打开Prisma Studio
npm run prisma:studio
```
## 环境变量
| 变量 | 描述 | 默认值 |
|------|------|--------|
| PORT | 服务端口 | 3000 |
| DATABASE_URL | 数据库连接 | - |
| REDIS_HOST | Redis主机 | localhost |
| REDIS_PORT | Redis端口 | 6379 |
| JWT_SECRET | JWT密钥 | - |
| JWT_ACCESS_EXPIRATION | AccessToken有效期 | 2h |
| JWT_REFRESH_EXPIRATION | RefreshToken有效期 | 30d |
| KAFKA_BROKERS | Kafka地址 | localhost:9092 |
## License
MIT

View File

@ -0,0 +1,125 @@
version: '3.8'
services:
identity-service:
build:
context: .
dockerfile: Dockerfile
container_name: identity-service
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/identity_db?schema=public
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET:-your-super-secret-key-change-in-production}
- JWT_ACCESS_EXPIRATION=2h
- JWT_REFRESH_EXPIRATION=30d
- KAFKA_BROKERS=kafka:29092
- KAFKA_CLIENT_ID=identity-service
- KAFKA_GROUP_ID=identity-service-group
- WALLET_ENCRYPTION_SALT=${WALLET_ENCRYPTION_SALT:-rwa-wallet-salt}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
kafka:
condition: service_started
networks:
- rwa-network
restart: unless-stopped
postgres:
image: postgres:15-alpine
container_name: identity-postgres
ports:
- '5432:5432'
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=identity_db
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
networks:
- rwa-network
redis:
image: redis:7-alpine
container_name: identity-redis
ports:
- '6379:6379'
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
networks:
- rwa-network
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: identity-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
volumes:
- zookeeper-data:/var/lib/zookeeper/data
- zookeeper-logs:/var/lib/zookeeper/log
networks:
- rwa-network
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: identity-kafka
depends_on:
- zookeeper
ports:
- '9092:9092'
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
KAFKA_LOG_RETENTION_HOURS: 168
volumes:
- kafka-data:/var/lib/kafka/data
networks:
- rwa-network
kafka-ui:
image: provectuslabs/kafka-ui:latest
container_name: identity-kafka-ui
ports:
- '8080:8080'
environment:
KAFKA_CLUSTERS_0_NAME: identity-cluster
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
depends_on:
- kafka
networks:
- rwa-network
volumes:
postgres-data:
redis-data:
zookeeper-data:
zookeeper-logs:
kafka-data:
networks:
rwa-network:
driver: bridge

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -0,0 +1,108 @@
{
"name": "identity-service",
"version": "2.0.0",
"description": "Identity & User Context Microservice - RWA Platform",
"author": "RWA Team",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"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",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "prisma db seed"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/cqrs": "^10.2.6",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.8.0",
"@scure/bip32": "^1.3.2",
"bech32": "^2.0.0",
"bip39": "^3.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.8.0",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.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": {
"^@app/(.*)$": "<rootDir>/$1",
"^@domain/(.*)$": "<rootDir>/domain/$1",
"^@application/(.*)$": "<rootDir>/application/$1",
"^@infrastructure/(.*)$": "<rootDir>/infrastructure/$1",
"^@api/(.*)$": "<rootDir>/api/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^@config/(.*)$": "<rootDir>/config/$1"
}
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@ -0,0 +1,176 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户账户表
model UserAccount {
id BigInt @id @default(autoincrement()) @map("user_id")
accountSequence BigInt @unique @map("account_sequence")
// 基本信息
phoneNumber String? @unique @map("phone_number") @db.VarChar(20)
nickname String @db.VarChar(100)
avatarUrl String? @map("avatar_url") @db.VarChar(500)
// 推荐信息
inviterSequence BigInt? @map("inviter_sequence")
referralCode String @unique @map("referral_code") @db.VarChar(10)
// 区域信息
provinceCode String @map("province_code") @db.VarChar(10)
cityCode String @map("city_code") @db.VarChar(10)
address String? @db.VarChar(500)
// KYC信息
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
realName String? @map("real_name") @db.VarChar(100)
idCardNumber String? @map("id_card_number") @db.VarChar(20)
idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500)
idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500)
kycVerifiedAt DateTime? @map("kyc_verified_at")
// 账户状态
status String @default("ACTIVE") @db.VarChar(20)
// 时间戳
registeredAt DateTime @default(now()) @map("registered_at")
lastLoginAt DateTime? @map("last_login_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
devices UserDevice[]
walletAddresses WalletAddress[]
events UserEvent[]
@@index([phoneNumber])
@@index([accountSequence])
@@index([referralCode])
@@index([inviterSequence])
@@index([provinceCode, cityCode])
@@index([kycStatus])
@@index([status])
@@map("user_accounts")
}
// 用户设备表
model UserDevice {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
deviceId String @map("device_id") @db.VarChar(100)
deviceName String? @map("device_name") @db.VarChar(100)
addedAt DateTime @default(now()) @map("added_at")
lastActiveAt DateTime @default(now()) @map("last_active_at")
user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, deviceId])
@@index([deviceId])
@@index([userId])
@@index([lastActiveAt])
@@map("user_devices")
}
// 区块链钱包地址表
model WalletAddress {
id BigInt @id @default(autoincrement()) @map("address_id")
userId BigInt @map("user_id")
chainType String @map("chain_type") @db.VarChar(20)
address String @db.VarChar(100)
encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text
status String @default("ACTIVE") @db.VarChar(20)
boundAt DateTime @default(now()) @map("bound_at")
user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, chainType])
@@unique([chainType, address])
@@index([userId])
@@index([address])
@@map("wallet_addresses")
}
// 设备Token表
model DeviceToken {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
deviceId String @map("device_id") @db.VarChar(100)
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
@@index([userId, deviceId])
@@index([expiresAt])
@@map("device_tokens")
}
// 用户事件表
model UserEvent {
id BigInt @id @default(autoincrement()) @map("event_id")
eventType String @map("event_type") @db.VarChar(50)
aggregateId String @map("aggregate_id") @db.VarChar(100)
aggregateType String @map("aggregate_type") @db.VarChar(50)
eventData Json @map("event_data")
userId BigInt? @map("user_id")
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(6)
version Int @default(1)
user UserAccount? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([aggregateType, aggregateId])
@@index([eventType])
@@index([userId])
@@index([occurredAt])
@@map("user_events")
}
// 短信验证码表
model SmsCode {
id BigInt @id @default(autoincrement())
phoneNumber String @map("phone_number") @db.VarChar(20)
code String @db.VarChar(6)
type String @db.VarChar(20) // REGISTER, LOGIN, BIND, RECOVER
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([phoneNumber, type])
@@index([expiresAt])
@@map("sms_codes")
}
// 死信队列表
model DeadLetterEvent {
id BigInt @id @default(autoincrement())
topic String @db.VarChar(100)
eventId String @map("event_id") @db.VarChar(50)
eventType String @map("event_type") @db.VarChar(50)
aggregateId String @map("aggregate_id") @db.VarChar(100)
aggregateType String @map("aggregate_type") @db.VarChar(50)
payload Json
errorMessage String @map("error_message") @db.Text
errorStack String? @map("error_stack") @db.Text
retryCount Int @default(0) @map("retry_count")
createdAt DateTime @default(now()) @map("created_at")
processedAt DateTime? @map("processed_at")
@@index([topic])
@@index([eventType])
@@index([createdAt])
@@index([processedAt])
@@map("dead_letter_events")
}

View File

@ -0,0 +1,27 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// 清理现有数据
await prisma.deadLetterEvent.deleteMany();
await prisma.smsCode.deleteMany();
await prisma.userEvent.deleteMany();
await prisma.deviceToken.deleteMany();
await prisma.walletAddress.deleteMany();
await prisma.userDevice.deleteMany();
await prisma.userAccount.deleteMany();
console.log('Database seeded successfully!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,314 @@
import {
Controller,
Post,
Get,
Delete,
Body,
UseGuards,
HttpCode,
HttpStatus,
Inject,
Logger,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
AutoCreateAccountDto,
RecoverByMnemonicDto,
RecoverByPhoneDto,
RefreshTokenDto,
SendSmsCodeDto,
BindPhoneDto,
RemoveDeviceDto,
} from '../dto/request';
import { AutoCreateAccountCommand } from '@application/commands/auto-create-account/auto-create-account.command';
import { RecoverByMnemonicCommand } from '@application/commands/recover-by-mnemonic/recover-by-mnemonic.command';
import { RecoverByPhoneCommand } from '@application/commands/recover-by-phone/recover-by-phone.command';
import { TokenService } from '@application/services/token.service';
import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service';
import {
IUserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@domain/repositories/user-account.repository.interface';
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
import { JwtAuthGuard } from '@shared/guards/jwt-auth.guard';
import { CurrentUser, CurrentDeviceId, Public } from '@shared/decorators';
import { ApplicationException } from '@shared/exceptions/application.exception';
@ApiTags('用户管理')
@Controller('user')
export class UserAccountController {
private readonly logger = new Logger(UserAccountController.name);
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly tokenService: TokenService,
private readonly smsService: SmsService,
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: IUserAccountRepository,
) {}
@Post('auto-create')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '自动创建账户' })
@ApiResponse({ status: 200, description: '创建成功' })
async autoCreate(@Body() dto: AutoCreateAccountDto) {
const command = new AutoCreateAccountCommand(
dto.deviceId,
dto.deviceName,
dto.inviterReferralCode,
dto.provinceCode,
dto.cityCode,
);
const result = await this.commandBus.execute(command);
return {
success: true,
message: '账户创建成功',
data: result,
};
}
@Post('recover-by-mnemonic')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用助记词恢复账户' })
@ApiResponse({ status: 200, description: '恢复成功' })
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
const command = new RecoverByMnemonicCommand(
dto.accountSequence,
dto.mnemonic,
dto.newDeviceId,
dto.deviceName,
);
const result = await this.commandBus.execute(command);
return {
success: true,
message: '账户恢复成功',
data: result,
};
}
@Post('recover-by-phone')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用手机号恢复账户' })
@ApiResponse({ status: 200, description: '恢复成功' })
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
const command = new RecoverByPhoneCommand(
dto.accountSequence,
dto.phoneNumber,
dto.smsCode,
dto.newDeviceId,
dto.deviceName,
);
const result = await this.commandBus.execute(command);
return {
success: true,
message: '账户恢复成功',
data: result,
};
}
@Post('refresh-token')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新Token' })
@ApiResponse({ status: 200, description: '刷新成功' })
async refreshToken(@Body() dto: RefreshTokenDto) {
const payload = await this.tokenService.verifyRefreshToken(dto.refreshToken);
// 验证设备
const account = await this.userRepository.findById(payload.userId);
if (!account) {
throw new ApplicationException('账户不存在');
}
if (!account.isDeviceAuthorized(dto.deviceId)) {
throw new ApplicationException('设备未授权');
}
// 更新设备活跃时间
account.updateDeviceActivity(dto.deviceId);
await this.userRepository.save(account);
// 生成新Token
const tokens = await this.tokenService.refreshTokens(dto.refreshToken);
return {
success: true,
message: 'Token刷新成功',
data: tokens,
};
}
@Post('send-sms-code')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '发送短信验证码' })
@ApiResponse({ status: 200, description: '发送成功' })
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
const result = await this.smsService.sendVerificationCode(
dto.phoneNumber,
dto.type as SmsType,
);
return {
success: true,
message: result.message,
};
}
@Post('bind-phone')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '绑定手机号' })
@ApiResponse({ status: 200, description: '绑定成功' })
async bindPhone(@Body() dto: BindPhoneDto, @CurrentUser() user: any) {
// 验证短信验证码
await this.smsService.verifyCode(dto.phoneNumber, SmsType.BIND, dto.smsCode);
// 获取账户
const account = await this.userRepository.findById(user.userId);
if (!account) {
throw new ApplicationException('账户不存在');
}
// 绑定手机号
const phoneNumber = PhoneNumber.create(dto.phoneNumber);
account.bindPhoneNumber(phoneNumber);
await this.userRepository.save(account);
return {
success: true,
message: '手机号绑定成功',
};
}
@Get('my-profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询我的资料' })
@ApiResponse({ status: 200, description: '查询成功' })
async getMyProfile(@CurrentUser() user: any) {
const account = await this.userRepository.findById(user.userId);
if (!account) {
throw new ApplicationException('账户不存在');
}
return {
success: true,
data: {
userId: account.userId,
accountSequence: account.accountSequence.value,
phoneNumber: account.phoneNumber?.masked() || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
provinceCode: account.provinceCode,
cityCode: account.cityCode,
address: account.address,
walletAddresses: account.getAllWalletAddresses().map((w) => ({
chainType: w.chainType,
address: w.address,
})),
kycStatus: account.kycStatus,
status: account.status,
registeredAt: account.registeredAt,
lastLoginAt: account.lastLoginAt,
},
};
}
@Get('my-devices')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询我的设备列表' })
@ApiResponse({ status: 200, description: '查询成功' })
async getMyDevices(
@CurrentUser() user: any,
@CurrentDeviceId() currentDeviceId: string,
) {
const account = await this.userRepository.findById(user.userId);
if (!account) {
throw new ApplicationException('账户不存在');
}
const devices = account.getAllDevices().map((device) => ({
deviceId: device.deviceId,
deviceName: device.deviceName,
addedAt: device.addedAt,
lastActiveAt: device.lastActiveAt,
isCurrent: device.deviceId === currentDeviceId,
}));
return {
success: true,
data: devices,
};
}
@Delete('remove-device')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '移除设备' })
@ApiResponse({ status: 200, description: '移除成功' })
async removeDevice(
@Body() dto: RemoveDeviceDto,
@CurrentUser() user: any,
@CurrentDeviceId() currentDeviceId: string,
) {
if (dto.deviceId === currentDeviceId) {
throw new ApplicationException('不能移除当前设备');
}
const account = await this.userRepository.findById(user.userId);
if (!account) {
throw new ApplicationException('账户不存在');
}
account.removeDevice(dto.deviceId);
await this.userRepository.save(account);
// 吊销该设备的Token
await this.tokenService.revokeAllDeviceTokens(user.userId, dto.deviceId);
return {
success: true,
message: '设备移除成功',
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出登录' })
@ApiResponse({ status: 200, description: '退出成功' })
async logout(
@CurrentUser() user: any,
@CurrentDeviceId() deviceId: string,
) {
await this.tokenService.revokeAllDeviceTokens(user.userId, deviceId);
return {
success: true,
message: '退出成功',
};
}
}

View File

@ -0,0 +1,178 @@
import { IsString, IsOptional, Length, IsNumber, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class AutoCreateAccountDto {
@ApiProperty({
description: '设备唯一标识',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsString()
deviceId: string;
@ApiPropertyOptional({
description: '设备名称',
example: 'iPhone 15 Pro',
})
@IsString()
@IsOptional()
deviceName?: string;
@ApiPropertyOptional({
description: '推荐码',
example: 'ABC123',
})
@IsString()
@Length(6, 6)
@IsOptional()
inviterReferralCode?: string;
@ApiPropertyOptional({
description: '省份代码',
example: '110000',
})
@IsString()
@IsOptional()
provinceCode?: string;
@ApiPropertyOptional({
description: '城市代码',
example: '110100',
})
@IsString()
@IsOptional()
cityCode?: string;
}
export class RecoverByMnemonicDto {
@ApiProperty({
description: '账户序列号',
example: 10001,
})
@IsNumber()
@Min(1)
@Type(() => Number)
accountSequence: number;
@ApiProperty({
description: '12个单词的助记词',
example: 'abandon ability able about above absent absorb abstract absurd abuse access accident',
})
@IsString()
mnemonic: string;
@ApiProperty({
description: '新设备ID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsString()
newDeviceId: string;
@ApiPropertyOptional({
description: '设备名称',
example: 'iPhone 15 Pro',
})
@IsString()
@IsOptional()
deviceName?: string;
}
export class RecoverByPhoneDto {
@ApiProperty({
description: '账户序列号',
example: 10001,
})
@IsNumber()
@Min(1)
@Type(() => Number)
accountSequence: number;
@ApiProperty({
description: '手机号',
example: '13800138000',
})
@IsString()
@Length(11, 11)
phoneNumber: string;
@ApiProperty({
description: '短信验证码',
example: '123456',
})
@IsString()
@Length(6, 6)
smsCode: string;
@ApiProperty({
description: '新设备ID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsString()
newDeviceId: string;
@ApiPropertyOptional({
description: '设备名称',
example: 'iPhone 15 Pro',
})
@IsString()
@IsOptional()
deviceName?: string;
}
export class RefreshTokenDto {
@ApiProperty({
description: 'Refresh Token',
})
@IsString()
refreshToken: string;
@ApiProperty({
description: '设备ID',
})
@IsString()
deviceId: string;
}
export class SendSmsCodeDto {
@ApiProperty({
description: '手机号',
example: '13800138000',
})
@IsString()
@Length(11, 11)
phoneNumber: string;
@ApiProperty({
description: '验证码类型',
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
example: 'BIND',
})
@IsString()
type: string;
}
export class BindPhoneDto {
@ApiProperty({
description: '手机号',
example: '13800138000',
})
@IsString()
@Length(11, 11)
phoneNumber: string;
@ApiProperty({
description: '短信验证码',
example: '123456',
})
@IsString()
@Length(6, 6)
smsCode: string;
}
export class RemoveDeviceDto {
@ApiProperty({
description: '要移除的设备ID',
})
@IsString()
deviceId: string;
}

View File

@ -0,0 +1,162 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WalletAddressDto {
@ApiProperty()
chainType: string;
@ApiProperty()
address: string;
}
export class DeviceDto {
@ApiProperty()
deviceId: string;
@ApiProperty()
deviceName: string;
@ApiProperty()
addedAt: Date;
@ApiProperty()
lastActiveAt: Date;
@ApiProperty()
isCurrent: boolean;
}
export class UserProfileDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiPropertyOptional()
phoneNumber?: string;
@ApiProperty()
nickname: string;
@ApiPropertyOptional()
avatarUrl?: string;
@ApiProperty()
referralCode: string;
@ApiProperty()
provinceCode: string;
@ApiProperty()
cityCode: string;
@ApiPropertyOptional()
address?: string;
@ApiProperty({ type: [WalletAddressDto] })
walletAddresses: WalletAddressDto[];
@ApiProperty()
kycStatus: string;
@ApiProperty()
status: string;
@ApiProperty()
registeredAt: Date;
@ApiPropertyOptional()
lastLoginAt?: Date;
}
export class AutoCreateAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiProperty()
referralCode: string;
@ApiProperty({
description: '助记词(仅返回一次,请妥善保管)',
})
mnemonic: string;
@ApiProperty()
walletAddresses: {
kava: string;
dst: string;
bsc: string;
};
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
accessTokenExpiresAt: Date;
@ApiProperty()
refreshTokenExpiresAt: Date;
}
export class RecoverAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiProperty()
nickname: string;
@ApiPropertyOptional()
avatarUrl?: string;
@ApiProperty()
referralCode: string;
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
accessTokenExpiresAt: Date;
@ApiProperty()
refreshTokenExpiresAt: Date;
}
export class TokenResponseDto {
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
accessTokenExpiresAt: Date;
@ApiProperty()
refreshTokenExpiresAt: Date;
}
export class ApiResponseDto<T> {
@ApiProperty()
success: boolean;
@ApiProperty()
message: string;
@ApiPropertyOptional()
data?: T;
@ApiPropertyOptional()
error?: string;
}

View File

@ -0,0 +1,92 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CqrsModule } from '@nestjs/cqrs';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ScheduleModule } from '@nestjs/schedule';
// Infrastructure
import { PrismaService } from './infrastructure/persistence/prisma/prisma.service';
import { UserAccountMapper } from './infrastructure/persistence/mappers/user-account.mapper';
import { UserAccountRepositoryImpl } from './infrastructure/persistence/repositories/user-account.repository.impl';
import { RedisModule } from './infrastructure/redis/redis.module';
import { KafkaModule } from './infrastructure/kafka/kafka.module';
import { WalletGeneratorService } from './infrastructure/external/blockchain/wallet-generator.service';
import { SmsService } from './infrastructure/external/sms/sms.service';
// Domain
import { USER_ACCOUNT_REPOSITORY } from './domain/repositories/user-account.repository.interface';
import { UserValidatorService } from './domain/services/user-validator.service';
// Application
import { TokenService } from './application/services/token.service';
import { AutoCreateAccountHandler } from './application/commands/auto-create-account/auto-create-account.handler';
import { RecoverByMnemonicHandler } from './application/commands/recover-by-mnemonic/recover-by-mnemonic.handler';
import { RecoverByPhoneHandler } from './application/commands/recover-by-phone/recover-by-phone.handler';
// API
import { UserAccountController } from './api/controllers/user-account.controller';
// Shared
import { JwtStrategy } from './shared/strategies/jwt.strategy';
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
const CommandHandlers = [
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,
];
const QueryHandlers = [];
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', `.env.${process.env.NODE_ENV || 'development'}`],
}),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET', 'default-secret'),
signOptions: {
expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '2h'),
},
}),
inject: [ConfigService],
}),
ScheduleModule.forRoot(),
CqrsModule,
RedisModule,
KafkaModule,
],
controllers: [UserAccountController],
providers: [
// Infrastructure
PrismaService,
UserAccountMapper,
{
provide: USER_ACCOUNT_REPOSITORY,
useClass: UserAccountRepositoryImpl,
},
WalletGeneratorService,
SmsService,
// Domain Services
UserValidatorService,
// Application Services
TokenService,
// Auth
JwtStrategy,
JwtAuthGuard,
// CQRS Handlers
...CommandHandlers,
...QueryHandlers,
],
exports: [PrismaService],
})
export class AppModule {}

View File

@ -0,0 +1,9 @@
export class AutoCreateAccountCommand {
constructor(
public readonly deviceId: string,
public readonly deviceName: string | undefined,
public readonly inviterReferralCode: string | undefined,
public readonly provinceCode: string | undefined,
public readonly cityCode: string | undefined,
) {}
}

View File

@ -0,0 +1,147 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
import { UserValidatorService } from '@domain/services/user-validator.service';
import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service';
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
import {
IUserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@domain/repositories/user-account.repository.interface';
import { TokenService } from '@application/services/token.service';
import { ApplicationException } from '@shared/exceptions/application.exception';
export interface AutoCreateAccountResult {
userId: string;
accountSequence: number;
referralCode: string;
mnemonic: string;
walletAddresses: {
kava: string;
dst: string;
bsc: string;
};
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
}
@CommandHandler(AutoCreateAccountCommand)
export class AutoCreateAccountHandler
implements ICommandHandler<AutoCreateAccountCommand, AutoCreateAccountResult>
{
private readonly logger = new Logger(AutoCreateAccountHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: IUserAccountRepository,
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(
command: AutoCreateAccountCommand,
): Promise<AutoCreateAccountResult> {
this.logger.log(`Creating account for device: ${command.deviceId}`);
// 1. 验证设备ID
const deviceValidation = await this.validatorService.validateDeviceId(
command.deviceId,
);
if (!deviceValidation.isValid) {
throw new ApplicationException(deviceValidation.errorMessage!);
}
// 2. 验证推荐码(如果有)
let inviterSequence = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation =
await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid) {
throw new ApplicationException(referralValidation.errorMessage!);
}
const inviter = await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence;
}
// 3. 生成账户序列号
const accountSequence = await this.userRepository.getNextAccountSequence();
// 4. 生成用户ID
const userId = String(accountSequence.value);
// 5. 创建用户账户
const account = UserAccount.createAutomatic({
userId,
accountSequence,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
inviterSequence,
provinceCode: command.provinceCode || 'DEFAULT',
cityCode: command.cityCode || 'DEFAULT',
});
// 6. 生成钱包
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
userId: account.userId,
deviceId: command.deviceId,
});
// 7. 绑定钱包
account.bindMultipleWalletAddresses(wallets);
// 8. 保存
await this.userRepository.save(account);
await this.userRepository.saveWallets(
account.userId,
Array.from(wallets.values()),
);
// 9. 生成Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
// 10. 发布事件
await this.eventPublisher.publishUserAccountCreated({
userId: account.userId,
accountSequence: account.accountSequence.value,
initialDeviceId: command.deviceId,
inviterSequence: inviterSequence?.value || null,
provinceCode: account.provinceCode,
cityCode: account.cityCode,
referralCode: account.referralCode.value,
});
this.logger.log(`Account created: ${account.userId}`);
return {
userId: account.userId,
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: mnemonic.value,
walletAddresses: {
kava: wallets.get('KAVA' as any)!.address,
dst: wallets.get('DST' as any)!.address,
bsc: wallets.get('BSC' as any)!.address,
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
};
}
}

View File

@ -0,0 +1,8 @@
export class RecoverByMnemonicCommand {
constructor(
public readonly accountSequence: number,
public readonly mnemonic: string,
public readonly newDeviceId: string,
public readonly deviceName: string | undefined,
) {}
}

View File

@ -0,0 +1,120 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject, Logger } from '@nestjs/common';
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
import { Mnemonic } from '@domain/value-objects/mnemonic.vo';
import { ChainType } from '@domain/enums/chain-type.enum';
import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service';
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
import {
IUserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@domain/repositories/user-account.repository.interface';
import { TokenService } from '@application/services/token.service';
import { ApplicationException } from '@shared/exceptions/application.exception';
export interface RecoverAccountResult {
userId: string;
accountSequence: number;
nickname: string;
avatarUrl: string | null;
referralCode: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
}
@CommandHandler(RecoverByMnemonicCommand)
export class RecoverByMnemonicHandler
implements ICommandHandler<RecoverByMnemonicCommand, RecoverAccountResult>
{
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: IUserAccountRepository,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(
command: RecoverByMnemonicCommand,
): Promise<RecoverAccountResult> {
this.logger.log(`Recovering account: ${command.accountSequence}`);
// 1. 查找账户
const accountSequence = AccountSequence.create(command.accountSequence);
const account =
await this.userRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new ApplicationException('账户序列号不存在');
}
if (!account.isActive) {
throw new ApplicationException('账户已冻结或注销');
}
// 2. 验证助记词
let mnemonic: Mnemonic;
try {
mnemonic = Mnemonic.create(command.mnemonic);
} catch {
throw new ApplicationException('助记词格式错误');
}
// 3. 验证助记词是否匹配账户
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
if (!kavaWallet) {
throw new ApplicationException('账户钱包信息异常');
}
const isValid = this.walletGenerator.verifyMnemonic(
mnemonic,
ChainType.KAVA,
kavaWallet.address,
);
if (!isValid) {
throw new ApplicationException('助记词错误');
}
// 4. 添加新设备
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin(command.newDeviceId);
// 5. 保存更新
await this.userRepository.save(account);
// 6. 生成Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
// 7. 发布事件
await this.eventPublisher.publishDeviceAdded({
userId: account.userId,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
deviceName: command.deviceName || '未命名设备',
});
this.logger.log(`Account recovered: ${account.userId}`);
return {
userId: account.userId,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
};
}
}

View File

@ -0,0 +1,9 @@
export class RecoverByPhoneCommand {
constructor(
public readonly accountSequence: number,
public readonly phoneNumber: string,
public readonly smsCode: string,
public readonly newDeviceId: string,
public readonly deviceName: string | undefined,
) {}
}

View File

@ -0,0 +1,110 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject, Logger } from '@nestjs/common';
import { RecoverByPhoneCommand } from './recover-by-phone.command';
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service';
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
import {
IUserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '@domain/repositories/user-account.repository.interface';
import { TokenService } from '@application/services/token.service';
import { ApplicationException } from '@shared/exceptions/application.exception';
export interface RecoverByPhoneResult {
userId: string;
accountSequence: number;
nickname: string;
avatarUrl: string | null;
referralCode: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
}
@CommandHandler(RecoverByPhoneCommand)
export class RecoverByPhoneHandler
implements ICommandHandler<RecoverByPhoneCommand, RecoverByPhoneResult>
{
private readonly logger = new Logger(RecoverByPhoneHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: IUserAccountRepository,
private readonly smsService: SmsService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: RecoverByPhoneCommand): Promise<RecoverByPhoneResult> {
this.logger.log(`Recovering account by phone: ${command.accountSequence}`);
// 1. 查找账户
const accountSequence = AccountSequence.create(command.accountSequence);
const account =
await this.userRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new ApplicationException('账户序列号不存在');
}
if (!account.isActive) {
throw new ApplicationException('账户已冻结或注销');
}
// 2. 验证手机号是否匹配
if (!account.phoneNumber) {
throw new ApplicationException('该账户未绑定手机号,请使用助记词恢复');
}
const phoneNumber = PhoneNumber.create(command.phoneNumber);
if (!account.phoneNumber.equals(phoneNumber)) {
throw new ApplicationException('手机号不匹配');
}
// 3. 验证短信验证码
await this.smsService.verifyCode(
command.phoneNumber,
SmsType.RECOVER,
command.smsCode,
);
// 4. 添加新设备
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin(command.newDeviceId);
// 5. 保存更新
await this.userRepository.save(account);
// 6. 生成Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
// 7. 发布事件
await this.eventPublisher.publishDeviceAdded({
userId: account.userId,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
deviceName: command.deviceName || '未命名设备',
});
this.logger.log(`Account recovered by phone: ${account.userId}`);
return {
userId: account.userId,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
};
}
}

View File

@ -0,0 +1,234 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto';
import { RedisService } from '@infrastructure/redis/redis.service';
import { ApplicationException } from '@shared/exceptions/application.exception';
export interface TokenPair {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: Date;
refreshTokenExpiresAt: Date;
}
export interface TokenPayload {
userId: string;
accountSequence: number;
deviceId: string;
}
export interface DecodedToken extends TokenPayload {
type: 'access' | 'refresh';
iat: number;
exp: number;
}
@Injectable()
export class TokenService {
private readonly logger = new Logger(TokenService.name);
private readonly accessTokenExpiration: string;
private readonly refreshTokenExpiration: string;
private readonly jwtSecret: string;
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly redisService: RedisService,
) {
this.jwtSecret = configService.get('JWT_SECRET', 'default-secret');
this.accessTokenExpiration = configService.get('JWT_ACCESS_EXPIRATION', '2h');
this.refreshTokenExpiration = configService.get('JWT_REFRESH_EXPIRATION', '30d');
}
async generateTokenPair(payload: TokenPayload): Promise<TokenPair> {
const now = new Date();
const accessToken = await this.jwtService.signAsync(
{
...payload,
type: 'access',
},
{
secret: this.jwtSecret,
expiresIn: this.accessTokenExpiration,
},
);
const refreshToken = await this.jwtService.signAsync(
{
...payload,
type: 'refresh',
},
{
secret: this.jwtSecret,
expiresIn: this.refreshTokenExpiration,
},
);
// 计算过期时间
const accessTokenExpiresAt = this.calculateExpirationDate(
this.accessTokenExpiration,
now,
);
const refreshTokenExpiresAt = this.calculateExpirationDate(
this.refreshTokenExpiration,
now,
);
this.logger.debug(`Generated token pair for user: ${payload.userId}`);
return {
accessToken,
refreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
};
}
async verifyAccessToken(token: string): Promise<DecodedToken> {
try {
const payload = await this.jwtService.verifyAsync<DecodedToken>(token, {
secret: this.jwtSecret,
});
if (payload.type !== 'access') {
throw new ApplicationException('无效的AccessToken');
}
// 检查是否在黑名单中
const tokenHash = this.hashToken(token);
const isBlacklisted = await this.redisService.isBlacklisted(tokenHash);
if (isBlacklisted) {
throw new ApplicationException('Token已被撤销');
}
return payload;
} catch (error) {
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException('AccessToken已过期或无效');
}
}
async verifyRefreshToken(token: string): Promise<DecodedToken> {
try {
const payload = await this.jwtService.verifyAsync<DecodedToken>(token, {
secret: this.jwtSecret,
});
if (payload.type !== 'refresh') {
throw new ApplicationException('无效的RefreshToken');
}
// 检查是否在黑名单中
const tokenHash = this.hashToken(token);
const isBlacklisted = await this.redisService.isBlacklisted(tokenHash);
if (isBlacklisted) {
throw new ApplicationException('Token已被撤销');
}
return payload;
} catch (error) {
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException('RefreshToken已过期或无效');
}
}
async refreshTokens(refreshToken: string): Promise<TokenPair> {
const payload = await this.verifyRefreshToken(refreshToken);
// 将旧的refresh token加入黑名单
await this.revokeToken(refreshToken, payload.exp);
// 生成新的token对
return this.generateTokenPair({
userId: payload.userId,
accountSequence: payload.accountSequence,
deviceId: payload.deviceId,
});
}
async revokeToken(token: string, expiresAt?: number): Promise<void> {
const tokenHash = this.hashToken(token);
// 计算剩余有效期
let ttl: number;
if (expiresAt) {
ttl = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
} else {
try {
const decoded = this.jwtService.decode(token) as DecodedToken;
ttl = Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
} catch {
ttl = 30 * 24 * 60 * 60; // 默认30天
}
}
if (ttl > 0) {
await this.redisService.addToBlacklist(tokenHash, ttl);
this.logger.debug(`Token revoked: ${tokenHash.substring(0, 16)}...`);
}
}
async revokeAllDeviceTokens(
userId: string,
deviceId: string,
): Promise<void> {
// 标记设备的所有token为无效
const key = `revoked:${userId}:${deviceId}`;
await this.redisService.set(key, Date.now().toString(), 30 * 24 * 60 * 60);
this.logger.log(`All tokens revoked for user ${userId}, device ${deviceId}`);
}
async revokeAllUserTokens(userId: string): Promise<void> {
// 标记用户的所有token为无效
const key = `revoked:${userId}:*`;
await this.redisService.set(
`revoked:all:${userId}`,
Date.now().toString(),
30 * 24 * 60 * 60,
);
this.logger.log(`All tokens revoked for user ${userId}`);
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private calculateExpirationDate(expiration: string, from: Date): Date {
const result = new Date(from);
const match = expiration.match(/^(\d+)([smhd])$/);
if (!match) {
throw new Error(`Invalid expiration format: ${expiration}`);
}
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
result.setSeconds(result.getSeconds() + value);
break;
case 'm':
result.setMinutes(result.getMinutes() + value);
break;
case 'h':
result.setHours(result.getHours() + value);
break;
case 'd':
result.setDate(result.getDate() + value);
break;
}
return result;
}
}

View File

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

View File

@ -0,0 +1,470 @@
import { AggregateRoot } from '@nestjs/cqrs';
import { AccountSequence } from '../../value-objects/account-sequence.vo';
import { PhoneNumber } from '../../value-objects/phone-number.vo';
import { ReferralCode } from '../../value-objects/referral-code.vo';
import { DeviceInfo } from '../../value-objects/device-info.vo';
import { KYCInfo } from '../../value-objects/kyc-info.vo';
import { WalletAddress } from '../../entities/wallet-address.entity';
import { ChainType } from '../../enums/chain-type.enum';
import { KYCStatus } from '../../enums/kyc-status.enum';
import { AccountStatus } from '../../enums/account-status.enum';
import { DomainException } from '@shared/exceptions/domain.exception';
import { UserAccountCreatedEvent } from '../../events/user-account-created.event';
import { DeviceAddedEvent } from '../../events/device-added.event';
import { PhoneNumberBoundEvent } from '../../events/phone-number-bound.event';
import { KYCSubmittedEvent } from '../../events/kyc-submitted.event';
export interface UserAccountProps {
userId: string;
accountSequence: AccountSequence;
devices: Map<string, DeviceInfo>;
phoneNumber: PhoneNumber | null;
nickname: string;
avatarUrl: string | null;
inviterSequence: AccountSequence | null;
referralCode: ReferralCode;
provinceCode: string;
cityCode: string;
address: string | null;
walletAddresses: Map<ChainType, WalletAddress>;
kycInfo: KYCInfo | null;
kycStatus: KYCStatus;
status: AccountStatus;
registeredAt: Date;
lastLoginAt: Date | null;
updatedAt: Date;
}
export class UserAccount extends AggregateRoot {
private _userId: string;
private _accountSequence: AccountSequence;
private _devices: Map<string, DeviceInfo>;
private _phoneNumber: PhoneNumber | null;
private _nickname: string;
private _avatarUrl: string | null;
private _inviterSequence: AccountSequence | null;
private _referralCode: ReferralCode;
private _provinceCode: string;
private _cityCode: string;
private _address: string | null;
private _walletAddresses: Map<ChainType, WalletAddress>;
private _kycInfo: KYCInfo | null;
private _kycStatus: KYCStatus;
private _status: AccountStatus;
private _registeredAt: Date;
private _lastLoginAt: Date | null;
private _updatedAt: Date;
private constructor() {
super();
}
// Getters
get userId(): string {
return this._userId;
}
get accountSequence(): AccountSequence {
return this._accountSequence;
}
get phoneNumber(): PhoneNumber | null {
return this._phoneNumber;
}
get nickname(): string {
return this._nickname;
}
get avatarUrl(): string | null {
return this._avatarUrl;
}
get inviterSequence(): AccountSequence | null {
return this._inviterSequence;
}
get referralCode(): ReferralCode {
return this._referralCode;
}
get provinceCode(): string {
return this._provinceCode;
}
get cityCode(): string {
return this._cityCode;
}
get address(): string | null {
return this._address;
}
get kycInfo(): KYCInfo | null {
return this._kycInfo;
}
get kycStatus(): KYCStatus {
return this._kycStatus;
}
get status(): AccountStatus {
return this._status;
}
get registeredAt(): Date {
return this._registeredAt;
}
get lastLoginAt(): Date | null {
return this._lastLoginAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
get isActive(): boolean {
return this._status === AccountStatus.ACTIVE;
}
get isKYCVerified(): boolean {
return this._kycStatus === KYCStatus.VERIFIED;
}
// Static Factory Methods
static createAutomatic(params: {
userId: string;
accountSequence: AccountSequence;
initialDeviceId: string;
deviceName?: string;
inviterSequence: AccountSequence | null;
provinceCode: string;
cityCode: string;
}): UserAccount {
const account = new UserAccount();
const devices = new Map<string, DeviceInfo>();
const now = new Date();
devices.set(
params.initialDeviceId,
new DeviceInfo(
params.initialDeviceId,
params.deviceName || '未命名设备',
now,
now,
),
);
const referralCode = ReferralCode.generate();
account._userId = params.userId;
account._accountSequence = params.accountSequence;
account._devices = devices;
account._phoneNumber = null;
account._nickname = `用户${params.accountSequence.value}`;
account._avatarUrl = null;
account._inviterSequence = params.inviterSequence;
account._referralCode = referralCode;
account._provinceCode = params.provinceCode;
account._cityCode = params.cityCode;
account._address = null;
account._walletAddresses = new Map();
account._kycInfo = null;
account._kycStatus = KYCStatus.NOT_VERIFIED;
account._status = AccountStatus.ACTIVE;
account._registeredAt = now;
account._lastLoginAt = null;
account._updatedAt = now;
account.apply(
new UserAccountCreatedEvent(
account._userId,
params.accountSequence.value,
params.initialDeviceId,
params.inviterSequence?.value || null,
params.provinceCode,
params.cityCode,
referralCode.value,
),
);
return account;
}
static fromPersistence(props: UserAccountProps): UserAccount {
const account = new UserAccount();
account._userId = props.userId;
account._accountSequence = props.accountSequence;
account._devices = props.devices;
account._phoneNumber = props.phoneNumber;
account._nickname = props.nickname;
account._avatarUrl = props.avatarUrl;
account._inviterSequence = props.inviterSequence;
account._referralCode = props.referralCode;
account._provinceCode = props.provinceCode;
account._cityCode = props.cityCode;
account._address = props.address;
account._walletAddresses = props.walletAddresses;
account._kycInfo = props.kycInfo;
account._kycStatus = props.kycStatus;
account._status = props.status;
account._registeredAt = props.registeredAt;
account._lastLoginAt = props.lastLoginAt;
account._updatedAt = props.updatedAt;
return account;
}
// Domain Methods
addDevice(deviceId: string, deviceName?: string): void {
this.ensureActive();
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainException('最多允许5个设备同时登录');
}
const now = new Date();
if (this._devices.has(deviceId)) {
this._devices.get(deviceId)!.updateActivity();
} else {
this._devices.set(
deviceId,
new DeviceInfo(
deviceId,
deviceName || '未命名设备',
now,
now,
),
);
this.apply(
new DeviceAddedEvent(
this._userId,
this._accountSequence.value,
deviceId,
deviceName || '未命名设备',
),
);
}
this._updatedAt = now;
}
removeDevice(deviceId: string): void {
this.ensureActive();
if (!this._devices.has(deviceId)) {
throw new DomainException('设备不存在');
}
if (this._devices.size <= 1) {
throw new DomainException('至少保留一个设备');
}
this._devices.delete(deviceId);
this._updatedAt = new Date();
}
isDeviceAuthorized(deviceId: string): boolean {
return this._devices.has(deviceId);
}
getAllDevices(): DeviceInfo[] {
return Array.from(this._devices.values());
}
getDeviceCount(): number {
return this._devices.size;
}
updateDeviceActivity(deviceId: string): void {
if (this._devices.has(deviceId)) {
this._devices.get(deviceId)!.updateActivity();
this._updatedAt = new Date();
}
}
bindPhoneNumber(phoneNumber: PhoneNumber): void {
this.ensureActive();
if (this._phoneNumber) {
throw new DomainException('已绑定手机号,不可重复绑定');
}
this._phoneNumber = phoneNumber;
this._updatedAt = new Date();
this.apply(new PhoneNumberBoundEvent(this._userId, phoneNumber.value));
}
bindWalletAddress(wallet: WalletAddress): void {
this.ensureActive();
if (this._walletAddresses.has(wallet.chainType)) {
throw new DomainException(`已绑定${wallet.chainType}地址`);
}
this._walletAddresses.set(wallet.chainType, wallet);
this._updatedAt = new Date();
}
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
this.ensureActive();
for (const [chainType, wallet] of wallets) {
if (this._walletAddresses.has(chainType)) {
throw new DomainException(`已绑定${chainType}地址`);
}
this._walletAddresses.set(chainType, wallet);
}
this._updatedAt = new Date();
}
getWalletAddress(chainType: ChainType): WalletAddress | null {
return this._walletAddresses.get(chainType) || null;
}
getAllWalletAddresses(): WalletAddress[] {
return Array.from(this._walletAddresses.values());
}
submitKYC(kycInfo: KYCInfo): void {
this.ensureActive();
if (this._kycStatus === KYCStatus.VERIFIED) {
throw new DomainException('已通过KYC认证不可重复提交');
}
this._kycInfo = kycInfo;
this._kycStatus = KYCStatus.PENDING;
this._updatedAt = new Date();
this.apply(
new KYCSubmittedEvent(
this._userId,
kycInfo.realName,
kycInfo.idCardNumber,
),
);
}
approveKYC(): void {
if (this._kycStatus !== KYCStatus.PENDING) {
throw new DomainException('只有待审核状态才能通过KYC');
}
this._kycStatus = KYCStatus.VERIFIED;
this._updatedAt = new Date();
}
rejectKYC(reason: string): void {
if (this._kycStatus !== KYCStatus.PENDING) {
throw new DomainException('只有待审核状态才能拒绝KYC');
}
this._kycStatus = KYCStatus.REJECTED;
this._updatedAt = new Date();
}
recordLogin(deviceId: string): void {
this.ensureActive();
if (!this._devices.has(deviceId)) {
throw new DomainException('设备未授权');
}
this._devices.get(deviceId)!.updateActivity();
this._lastLoginAt = new Date();
this._updatedAt = new Date();
}
updateProfile(params: {
nickname?: string;
avatarUrl?: string;
address?: string;
}): void {
this.ensureActive();
if (params.nickname !== undefined) {
if (params.nickname.trim().length < 2) {
throw new DomainException('昵称至少需要2个字符');
}
this._nickname = params.nickname;
}
if (params.avatarUrl !== undefined) {
this._avatarUrl = params.avatarUrl;
}
if (params.address !== undefined) {
this._address = params.address;
}
this._updatedAt = new Date();
}
freeze(reason: string): void {
if (this._status === AccountStatus.FROZEN) {
throw new DomainException('账户已冻结');
}
if (this._status === AccountStatus.DEACTIVATED) {
throw new DomainException('账户已注销,无法冻结');
}
this._status = AccountStatus.FROZEN;
this._updatedAt = new Date();
}
unfreeze(): void {
if (this._status !== AccountStatus.FROZEN) {
throw new DomainException('账户未冻结');
}
this._status = AccountStatus.ACTIVE;
this._updatedAt = new Date();
}
deactivate(): void {
if (this._status === AccountStatus.DEACTIVATED) {
throw new DomainException('账户已注销');
}
this._status = AccountStatus.DEACTIVATED;
this._updatedAt = new Date();
}
private ensureActive(): void {
if (this._status !== AccountStatus.ACTIVE) {
throw new DomainException('账户已冻结或注销');
}
}
// Persistence helpers
toPersistenceData(): object {
return {
userId: this._userId,
accountSequence: this._accountSequence.value,
phoneNumber: this._phoneNumber?.value || null,
nickname: this._nickname,
avatarUrl: this._avatarUrl,
inviterSequence: this._inviterSequence?.value || null,
referralCode: this._referralCode.value,
provinceCode: this._provinceCode,
cityCode: this._cityCode,
address: this._address,
kycStatus: this._kycStatus,
realName: this._kycInfo?.realName || null,
idCardNumber: this._kycInfo?.idCardNumber || null,
idCardFrontUrl: this._kycInfo?.idCardFrontUrl || null,
idCardBackUrl: this._kycInfo?.idCardBackUrl || null,
status: this._status,
lastLoginAt: this._lastLoginAt,
updatedAt: this._updatedAt,
};
}
}

View File

@ -0,0 +1 @@
export * from './wallet-address.entity';

View File

@ -0,0 +1,90 @@
import { ChainType } from '../enums/chain-type.enum';
export class WalletAddress {
private constructor(
private readonly _addressId: string,
private readonly _userId: string,
private readonly _chainType: ChainType,
private readonly _address: string,
private readonly _encryptedMnemonic: string,
private _status: 'ACTIVE' | 'DISABLED',
private readonly _boundAt: Date,
) {}
static create(params: {
addressId: string;
userId: string;
chainType: ChainType;
address: string;
encryptedMnemonic: string;
status?: 'ACTIVE' | 'DISABLED';
boundAt?: Date;
}): WalletAddress {
return new WalletAddress(
params.addressId,
params.userId,
params.chainType,
params.address,
params.encryptedMnemonic,
params.status || 'ACTIVE',
params.boundAt || new Date(),
);
}
get addressId(): string {
return this._addressId;
}
get userId(): string {
return this._userId;
}
get chainType(): ChainType {
return this._chainType;
}
get address(): string {
return this._address;
}
get encryptedMnemonic(): string {
return this._encryptedMnemonic;
}
get status(): 'ACTIVE' | 'DISABLED' {
return this._status;
}
get boundAt(): Date {
return this._boundAt;
}
get isActive(): boolean {
return this._status === 'ACTIVE';
}
disable(): void {
this._status = 'DISABLED';
}
enable(): void {
this._status = 'ACTIVE';
}
maskedAddress(): string {
if (this._address.length <= 12) {
return this._address;
}
return `${this._address.slice(0, 8)}...${this._address.slice(-4)}`;
}
toJSON(): object {
return {
addressId: this._addressId,
chainType: this._chainType,
address: this._address,
status: this._status,
boundAt: this._boundAt.toISOString(),
};
}
}

View File

@ -0,0 +1,5 @@
export enum AccountStatus {
ACTIVE = 'ACTIVE',
FROZEN = 'FROZEN',
DEACTIVATED = 'DEACTIVATED',
}

View File

@ -0,0 +1,20 @@
export enum ChainType {
KAVA = 'KAVA',
DST = 'DST',
BSC = 'BSC',
}
export const CHAIN_CONFIG = {
[ChainType.KAVA]: {
prefix: 'kava',
derivationPath: "m/44'/459'/0'/0/0",
},
[ChainType.DST]: {
prefix: 'dst',
derivationPath: "m/44'/118'/0'/0/0",
},
[ChainType.BSC]: {
prefix: '0x',
derivationPath: "m/44'/60'/0'/0/0",
},
};

View File

@ -0,0 +1,3 @@
export * from './chain-type.enum';
export * from './kyc-status.enum';
export * from './account-status.enum';

View File

@ -0,0 +1,6 @@
export enum KYCStatus {
NOT_VERIFIED = 'NOT_VERIFIED',
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
REJECTED = 'REJECTED',
}

View File

@ -0,0 +1,33 @@
import { DomainEvent } from './domain-event.base';
export class DeviceAddedEvent extends DomainEvent {
constructor(
public readonly userId: string,
public readonly accountSequence: number,
public readonly deviceId: string,
public readonly deviceName: string,
) {
super();
}
get eventType(): string {
return 'DeviceAdded';
}
get aggregateId(): string {
return this.userId;
}
get aggregateType(): string {
return 'UserAccount';
}
toPayload(): object {
return {
userId: this.userId,
accountSequence: this.accountSequence,
deviceId: this.deviceId,
deviceName: this.deviceName,
};
}
}

View File

@ -0,0 +1,15 @@
import { v4 as uuidv4 } from 'uuid';
export abstract class DomainEvent {
public readonly eventId: string;
public readonly occurredAt: Date;
constructor() {
this.eventId = uuidv4();
this.occurredAt = new Date();
}
abstract get eventType(): string;
abstract get aggregateId(): string;
abstract get aggregateType(): string;
}

View File

@ -0,0 +1,5 @@
export * from './domain-event.base';
export * from './user-account-created.event';
export * from './device-added.event';
export * from './phone-number-bound.event';
export * from './kyc-submitted.event';

View File

@ -0,0 +1,31 @@
import { DomainEvent } from './domain-event.base';
export class KYCSubmittedEvent extends DomainEvent {
constructor(
public readonly userId: string,
public readonly realName: string,
public readonly idCardNumber: string,
) {
super();
}
get eventType(): string {
return 'KYCSubmitted';
}
get aggregateId(): string {
return this.userId;
}
get aggregateType(): string {
return 'UserAccount';
}
toPayload(): object {
return {
userId: this.userId,
realName: this.realName,
idCardNumber: this.idCardNumber,
};
}
}

View File

@ -0,0 +1,29 @@
import { DomainEvent } from './domain-event.base';
export class PhoneNumberBoundEvent extends DomainEvent {
constructor(
public readonly userId: string,
public readonly phoneNumber: string,
) {
super();
}
get eventType(): string {
return 'PhoneNumberBound';
}
get aggregateId(): string {
return this.userId;
}
get aggregateType(): string {
return 'UserAccount';
}
toPayload(): object {
return {
userId: this.userId,
phoneNumber: this.phoneNumber,
};
}
}

View File

@ -0,0 +1,39 @@
import { DomainEvent } from './domain-event.base';
export class UserAccountCreatedEvent extends DomainEvent {
constructor(
public readonly userId: string,
public readonly accountSequence: number,
public readonly initialDeviceId: string,
public readonly inviterSequence: number | null,
public readonly provinceCode: string,
public readonly cityCode: string,
public readonly referralCode: string,
) {
super();
}
get eventType(): string {
return 'UserAccountCreated';
}
get aggregateId(): string {
return this.userId;
}
get aggregateType(): string {
return 'UserAccount';
}
toPayload(): object {
return {
userId: this.userId,
accountSequence: this.accountSequence,
initialDeviceId: this.initialDeviceId,
inviterSequence: this.inviterSequence,
provinceCode: this.provinceCode,
cityCode: this.cityCode,
referralCode: this.referralCode,
};
}
}

View File

@ -0,0 +1 @@
export * from './user-account.repository.interface';

View File

@ -0,0 +1,78 @@
import { UserAccount } from '../aggregates/user-account/user-account.aggregate';
import { AccountSequence } from '../value-objects/account-sequence.vo';
import { PhoneNumber } from '../value-objects/phone-number.vo';
import { ReferralCode } from '../value-objects/referral-code.vo';
import { ChainType } from '../enums/chain-type.enum';
import { WalletAddress } from '../entities/wallet-address.entity';
export interface IUserAccountRepository {
/**
*
*/
save(account: UserAccount): Promise<void>;
/**
*
*/
saveWallets(userId: string, wallets: WalletAddress[]): Promise<void>;
/**
* ID查找账户
*/
findById(userId: string): Promise<UserAccount | null>;
/**
*
*/
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
/**
* ID查找账户
*/
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
/**
*
*/
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
/**
*
*/
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
/**
*
*/
findByWalletAddress(
chainType: ChainType,
address: string,
): Promise<UserAccount | null>;
/**
*
*/
getNextAccountSequence(): Promise<AccountSequence>;
/**
* ID是否已存在
*/
existsByDeviceId(deviceId: string): Promise<boolean>;
/**
*
*/
existsByPhoneNumber(phoneNumber: PhoneNumber): Promise<boolean>;
/**
*
*/
existsByReferralCode(referralCode: ReferralCode): Promise<boolean>;
/**
*
*/
removeDevice(userId: string, deviceId: string): Promise<void>;
}
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');

View File

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

View File

@ -0,0 +1,80 @@
import { Injectable, Inject } from '@nestjs/common';
import { PhoneNumber } from '../value-objects/phone-number.vo';
import { ReferralCode } from '../value-objects/referral-code.vo';
import { ChainType } from '../enums/chain-type.enum';
import {
IUserAccountRepository,
USER_ACCOUNT_REPOSITORY,
} from '../repositories/user-account.repository.interface';
export interface ValidationResult {
isValid: boolean;
errorMessage?: string;
data?: any;
}
@Injectable()
export class UserValidatorService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: IUserAccountRepository,
) {}
async validateDeviceId(deviceId: string): Promise<ValidationResult> {
if (!deviceId || deviceId.trim().length === 0) {
return { isValid: false, errorMessage: '设备ID不能为空' };
}
const exists = await this.userRepository.existsByDeviceId(deviceId);
if (exists) {
return { isValid: false, errorMessage: '该设备已创建账户' };
}
return { isValid: true };
}
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
const exists = await this.userRepository.existsByPhoneNumber(phoneNumber);
if (exists) {
return { isValid: false, errorMessage: '该手机号已注册' };
}
return { isValid: true };
}
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
const inviter = await this.userRepository.findByReferralCode(referralCode);
if (!inviter) {
return { isValid: false, errorMessage: '推荐码不存在' };
}
if (!inviter.isActive) {
return { isValid: false, errorMessage: '推荐人账户已冻结或注销' };
}
return {
isValid: true,
data: {
inviterUserId: inviter.userId,
inviterSequence: inviter.accountSequence.value,
},
};
}
async validateWalletAddress(
chainType: ChainType,
address: string,
): Promise<ValidationResult> {
const existing = await this.userRepository.findByWalletAddress(
chainType,
address,
);
if (existing) {
return { isValid: false, errorMessage: '该地址已被其他账户绑定' };
}
return { isValid: true };
}
}

View File

@ -0,0 +1,33 @@
import { DomainException } from '@shared/exceptions/domain.exception';
export class AccountSequence {
private readonly _value: number;
private constructor(value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainException('账户序列号必须是大于0的整数');
}
this._value = value;
}
static create(value: number): AccountSequence {
return new AccountSequence(value);
}
static next(current: AccountSequence): AccountSequence {
return new AccountSequence(current.value + 1);
}
get value(): number {
return this._value;
}
equals(other: AccountSequence): boolean {
if (!other) return false;
return this._value === other.value;
}
toString(): string {
return String(this._value);
}
}

View File

@ -0,0 +1,38 @@
export class DeviceInfo {
private _lastActiveAt: Date;
constructor(
public readonly deviceId: string,
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
) {
if (!deviceId || deviceId.trim().length === 0) {
throw new Error('设备ID不能为空');
}
this._lastActiveAt = lastActiveAt;
}
get lastActiveAt(): Date {
return this._lastActiveAt;
}
updateActivity(): void {
this._lastActiveAt = new Date();
}
isActive(thresholdDays: number = 30): boolean {
const threshold = new Date();
threshold.setDate(threshold.getDate() - thresholdDays);
return this._lastActiveAt >= threshold;
}
toJSON(): object {
return {
deviceId: this.deviceId,
deviceName: this.deviceName,
addedAt: this.addedAt.toISOString(),
lastActiveAt: this._lastActiveAt.toISOString(),
};
}
}

View File

@ -0,0 +1,6 @@
export * from './account-sequence.vo';
export * from './phone-number.vo';
export * from './mnemonic.vo';
export * from './device-info.vo';
export * from './referral-code.vo';
export * from './kyc-info.vo';

View File

@ -0,0 +1,74 @@
import { DomainException } from '@shared/exceptions/domain.exception';
export class KYCInfo {
constructor(
public readonly realName: string,
public readonly idCardNumber: string,
public readonly idCardFrontUrl: string,
public readonly idCardBackUrl: string,
) {
this.validateRealName(realName);
this.validateIdCardNumber(idCardNumber);
this.validateUrl(idCardFrontUrl, '身份证正面图片');
this.validateUrl(idCardBackUrl, '身份证反面图片');
}
private validateRealName(name: string): void {
if (!name || name.trim().length < 2) {
throw new DomainException('真实姓名至少需要2个字符');
}
if (name.length > 50) {
throw new DomainException('真实姓名不能超过50个字符');
}
}
private validateIdCardNumber(idCard: string): void {
// 18位身份证号校验
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/;
if (!pattern.test(idCard)) {
throw new DomainException('身份证号格式错误');
}
}
private validateUrl(url: string, fieldName: string): void {
if (!url || url.trim().length === 0) {
throw new DomainException(`${fieldName}URL不能为空`);
}
// 简单URL格式校验
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new DomainException(`${fieldName}URL格式错误`);
}
}
static create(params: {
realName: string;
idCardNumber: string;
idCardFrontUrl: string;
idCardBackUrl: string;
}): KYCInfo {
return new KYCInfo(
params.realName,
params.idCardNumber,
params.idCardFrontUrl,
params.idCardBackUrl,
);
}
maskedIdCardNumber(): string {
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
}
maskedRealName(): string {
if (this.realName.length <= 1) {
return '*';
}
return this.realName[0] + '*'.repeat(this.realName.length - 1);
}
toJSON(): object {
return {
realName: this.maskedRealName(),
idCardNumber: this.maskedIdCardNumber(),
};
}
}

View File

@ -0,0 +1,49 @@
import * as bip39 from 'bip39';
import { DomainException } from '@shared/exceptions/domain.exception';
export class Mnemonic {
private readonly _value: string;
private constructor(value: string) {
const normalized = value.trim().toLowerCase();
if (!bip39.validateMnemonic(normalized)) {
throw new DomainException('助记词格式错误必须是有效的BIP39助记词');
}
this._value = normalized;
}
static generate(): Mnemonic {
const mnemonic = bip39.generateMnemonic(128); // 12 words
return new Mnemonic(mnemonic);
}
static create(value: string): Mnemonic {
return new Mnemonic(value);
}
get value(): string {
return this._value;
}
toSeed(): Buffer {
return bip39.mnemonicToSeedSync(this._value);
}
getWords(): string[] {
return this._value.split(' ');
}
getWordCount(): number {
return this.getWords().length;
}
equals(other: Mnemonic): boolean {
if (!other) return false;
return this._value === other.value;
}
// 不暴露原始值的toString
toString(): string {
return '[MNEMONIC HIDDEN]';
}
}

View File

@ -0,0 +1,34 @@
import { DomainException } from '@shared/exceptions/domain.exception';
export class PhoneNumber {
private readonly _value: string;
private constructor(value: string) {
const normalized = value.replace(/\s+/g, '');
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new DomainException('手机号格式错误必须是11位中国大陆手机号');
}
this._value = normalized;
}
static create(value: string): PhoneNumber {
return new PhoneNumber(value);
}
get value(): string {
return this._value;
}
masked(): string {
return this._value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
equals(other: PhoneNumber): boolean {
if (!other) return false;
return this._value === other.value;
}
toString(): string {
return this.masked();
}
}

View File

@ -0,0 +1,39 @@
import { DomainException } from '@shared/exceptions/domain.exception';
export class ReferralCode {
private readonly _value: string;
private constructor(value: string) {
const normalized = value.toUpperCase().trim();
if (!/^[A-Z0-9]{6}$/.test(normalized)) {
throw new DomainException('推荐码格式错误必须是6位字母数字组合');
}
this._value = normalized;
}
static generate(): ReferralCode {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return new ReferralCode(code);
}
static create(value: string): ReferralCode {
return new ReferralCode(value);
}
get value(): string {
return this._value;
}
equals(other: ReferralCode): boolean {
if (!other) return false;
return this._value === other.value;
}
toString(): string {
return this._value;
}
}

View File

@ -0,0 +1,212 @@
import { Injectable, Logger } from '@nestjs/common';
import { HDKey } from '@scure/bip32';
import {
createHash,
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'crypto';
import { bech32 } from 'bech32';
import { ethers } from 'ethers';
import { ConfigService } from '@nestjs/config';
import { Mnemonic } from '@domain/value-objects/mnemonic.vo';
import { WalletAddress } from '@domain/entities/wallet-address.entity';
import { ChainType, CHAIN_CONFIG } from '@domain/enums/chain-type.enum';
import { DomainException } from '@shared/exceptions/domain.exception';
import { v4 as uuidv4 } from 'uuid';
export interface WalletSystemResult {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
}
export interface EncryptedMnemonicData {
encrypted: string;
authTag: string;
iv: string;
}
@Injectable()
export class WalletGeneratorService {
private readonly logger = new Logger(WalletGeneratorService.name);
private readonly encryptionSalt: string;
constructor(private readonly configService: ConfigService) {
this.encryptionSalt = configService.get(
'WALLET_ENCRYPTION_SALT',
'rwa-wallet-salt',
);
}
generateWalletSystem(params: {
userId: string;
deviceId: string;
}): WalletSystemResult {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(
params.deviceId,
params.userId,
);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const address = this.deriveAddress(chainType, mnemonic);
const encryptedMnemonic = this.encryptMnemonic(
mnemonic.value,
encryptionKey,
);
const wallet = WalletAddress.create({
addressId: `addr_${uuidv4()}`,
userId: params.userId,
chainType,
address,
encryptedMnemonic: JSON.stringify(encryptedMnemonic),
});
wallets.set(chainType, wallet);
}
this.logger.debug(`Generated wallet system for user: ${params.userId}`);
return { mnemonic, wallets };
}
recoverWalletSystem(params: {
userId: string;
mnemonic: Mnemonic;
deviceId: string;
}): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(
params.deviceId,
params.userId,
);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const address = this.deriveAddress(chainType, params.mnemonic);
const encryptedMnemonic = this.encryptMnemonic(
params.mnemonic.value,
encryptionKey,
);
const wallet = WalletAddress.create({
addressId: `addr_${uuidv4()}`,
userId: params.userId,
chainType,
address,
encryptedMnemonic: JSON.stringify(encryptedMnemonic),
});
wallets.set(chainType, wallet);
}
this.logger.debug(`Recovered wallet system for user: ${params.userId}`);
return wallets;
}
deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
const seed = mnemonic.toSeed();
const config = CHAIN_CONFIG[chainType];
switch (chainType) {
case ChainType.KAVA:
case ChainType.DST:
return this.deriveCosmosAddress(
seed,
config.derivationPath,
config.prefix,
);
case ChainType.BSC:
return this.deriveEVMAddress(seed, config.derivationPath);
default:
throw new DomainException(`不支持的链类型: ${chainType}`);
}
}
verifyMnemonic(
mnemonic: Mnemonic,
chainType: ChainType,
expectedAddress: string,
): boolean {
const derivedAddress = this.deriveAddress(chainType, mnemonic);
return derivedAddress.toLowerCase() === expectedAddress.toLowerCase();
}
private deriveCosmosAddress(
seed: Buffer,
path: string,
prefix: string,
): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.publicKey) {
throw new DomainException('无法派生公钥');
}
const pubkey = childKey.publicKey;
const hash = createHash('sha256').update(pubkey).digest();
const addressHash = createHash('ripemd160').update(hash).digest();
const words = bech32.toWords(addressHash);
return bech32.encode(prefix, words);
}
private deriveEVMAddress(seed: Buffer, path: string): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.privateKey) {
throw new DomainException('无法派生私钥');
}
const wallet = new ethers.Wallet(childKey.privateKey);
return wallet.address;
}
encryptMnemonic(mnemonic: string, key: string): EncryptedMnemonicData {
const derivedKey = scryptSync(key, this.encryptionSalt, 32);
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', derivedKey, iv);
let encrypted = cipher.update(mnemonic, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encrypted,
authTag: authTag.toString('hex'),
iv: iv.toString('hex'),
};
}
decryptMnemonic(
encryptedData: EncryptedMnemonicData,
key: string,
): string {
const derivedKey = scryptSync(key, this.encryptionSalt, 32);
const iv = Buffer.from(encryptedData.iv, 'hex');
const authTag = Buffer.from(encryptedData.authTag, 'hex');
const decipher = createDecipheriv('aes-256-gcm', derivedKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
}

View File

@ -0,0 +1,150 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '../../redis/redis.service';
import { ApplicationException } from '@shared/exceptions/application.exception';
export enum SmsType {
REGISTER = 'REGISTER',
LOGIN = 'LOGIN',
BIND = 'BIND',
RECOVER = 'RECOVER',
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
private readonly codeLength = 6;
private readonly codeTtl = 300; // 5分钟
private readonly sendInterval = 60; // 发送间隔60秒
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
) {}
async sendVerificationCode(
phoneNumber: string,
type: SmsType,
): Promise<{ success: boolean; message: string }> {
// 检查发送间隔
const intervalKey = `sms:interval:${type}:${phoneNumber}`;
const hasInterval = await this.redisService.exists(intervalKey);
if (hasInterval) {
const ttl = await this.redisService.ttl(intervalKey);
throw new ApplicationException(`${ttl}秒后再试`);
}
// 生成验证码
const code = this.generateCode();
// 发送短信(调用第三方服务)
try {
await this.sendSms(phoneNumber, code, type);
} catch (error) {
this.logger.error(`Failed to send SMS to ${phoneNumber}`, error);
throw new ApplicationException('短信发送失败,请稍后重试');
}
// 保存验证码到Redis
await this.redisService.setSmsCode(phoneNumber, type, code, this.codeTtl);
// 设置发送间隔
await this.redisService.set(intervalKey, '1', this.sendInterval);
this.logger.log(`SMS code sent to ${phoneNumber} for ${type}`);
return { success: true, message: '验证码已发送' };
}
async verifyCode(
phoneNumber: string,
type: SmsType,
code: string,
): Promise<boolean> {
const storedCode = await this.redisService.getSmsCode(phoneNumber, type);
if (!storedCode) {
throw new ApplicationException('验证码已过期,请重新获取');
}
if (storedCode !== code) {
throw new ApplicationException('验证码错误');
}
// 验证成功后删除验证码
await this.redisService.deleteSmsCode(phoneNumber, type);
this.logger.log(`SMS code verified for ${phoneNumber}`);
return true;
}
private generateCode(): string {
let code = '';
for (let i = 0; i < this.codeLength; i++) {
code += Math.floor(Math.random() * 10).toString();
}
return code;
}
private async sendSms(
phoneNumber: string,
code: string,
type: SmsType,
): Promise<void> {
const provider = this.configService.get('SMS_PROVIDER', 'aliyun');
switch (provider) {
case 'aliyun':
await this.sendAliyunSms(phoneNumber, code, type);
break;
case 'tencent':
await this.sendTencentSms(phoneNumber, code, type);
break;
default:
// 开发环境:打印验证码
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.debug(`[DEV] SMS Code for ${phoneNumber}: ${code}`);
return;
}
throw new Error(`Unsupported SMS provider: ${provider}`);
}
}
private async sendAliyunSms(
phoneNumber: string,
code: string,
type: SmsType,
): Promise<void> {
const accessKeyId = this.configService.get('SMS_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get('SMS_ACCESS_KEY_SECRET');
const signName = this.configService.get('SMS_SIGN_NAME');
const templateCode = this.configService.get('SMS_TEMPLATE_CODE');
if (!accessKeyId || !accessKeySecret) {
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.debug(`[DEV] Aliyun SMS Code for ${phoneNumber}: ${code}`);
return;
}
throw new Error('Aliyun SMS credentials not configured');
}
// 实际调用阿里云短信API
// 这里使用简化实现,实际项目中应该使用@alicloud/dysmsapi20170525
this.logger.log(`Sending Aliyun SMS to ${phoneNumber}, template: ${templateCode}`);
// TODO: 实现阿里云短信发送
// const client = new Dysmsapi({...});
// await client.sendSms({...});
}
private async sendTencentSms(
phoneNumber: string,
code: string,
type: SmsType,
): Promise<void> {
// TODO: 实现腾讯云短信发送
this.logger.log(`Sending Tencent SMS to ${phoneNumber}`);
}
}

View File

@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../persistence/prisma/prisma.service';
import { DomainEventMessage } from './event-publisher.service';
@Injectable()
export class DeadLetterService {
private readonly logger = new Logger(DeadLetterService.name);
constructor(private readonly prisma: PrismaService) {}
async saveFailedEvent(
topic: string,
message: DomainEventMessage,
error: Error,
retryCount: number,
): Promise<void> {
await this.prisma.deadLetterEvent.create({
data: {
topic,
eventId: message.eventId,
eventType: message.eventType,
aggregateId: message.aggregateId,
aggregateType: message.aggregateType,
payload: message.payload,
errorMessage: error.message,
errorStack: error.stack,
retryCount,
createdAt: new Date(),
},
});
this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`);
}
async getFailedEvents(limit: number = 100): Promise<any[]> {
return this.prisma.deadLetterEvent.findMany({
where: { processedAt: null },
orderBy: { createdAt: 'asc' },
take: limit,
});
}
async markAsProcessed(id: bigint): Promise<void> {
await this.prisma.deadLetterEvent.update({
where: { id },
data: { processedAt: new Date() },
});
this.logger.log(`Dead letter event marked as processed: ${id}`);
}
async incrementRetryCount(id: bigint): Promise<void> {
await this.prisma.deadLetterEvent.update({
where: { id },
data: { retryCount: { increment: 1 } },
});
}
async getStatistics(): Promise<{
total: number;
pending: number;
processed: number;
byTopic: Record<string, number>;
}> {
const [total, pending, processed, byTopic] = await Promise.all([
this.prisma.deadLetterEvent.count(),
this.prisma.deadLetterEvent.count({ where: { processedAt: null } }),
this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }),
this.prisma.deadLetterEvent.groupBy({
by: ['topic'],
_count: true,
where: { processedAt: null },
}),
]);
const topicStats: Record<string, number> = {};
for (const item of byTopic) {
topicStats[item.topic] = item._count;
}
return { total, pending, processed, byTopic: topicStats };
}
}

View File

@ -0,0 +1,237 @@
import { Controller, Logger } from '@nestjs/common';
import {
MessagePattern,
Payload,
Ctx,
KafkaContext,
} from '@nestjs/microservices';
import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service';
@Controller()
export class EventConsumerController {
private readonly logger = new Logger(EventConsumerController.name);
@MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED)
async handleUserAccountCreated(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
const { offset } = context.getMessage();
const partition = context.getPartition();
this.logger.log(
`Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
);
try {
await this.processUserAccountCreated(message.payload);
this.logger.log(
`Successfully processed UserAccountCreated: ${message.eventId}`,
);
} catch (error) {
this.logger.error(
`Failed to process UserAccountCreated: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED)
async handleDeviceAdded(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
const { offset } = context.getMessage();
const partition = context.getPartition();
this.logger.log(
`Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
);
try {
await this.processDeviceAdded(message.payload);
this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process DeviceAdded: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.PHONE_BOUND)
async handlePhoneBound(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
const { offset } = context.getMessage();
const partition = context.getPartition();
this.logger.log(
`Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`,
);
try {
await this.processPhoneBound(message.payload);
this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process PhoneBound: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED)
async handleKYCSubmitted(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
this.logger.log(`Received KYCSubmitted event: ${message.eventId}`);
try {
await this.processKYCSubmitted(message.payload);
this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process KYCSubmitted: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.KYC_APPROVED)
async handleKYCApproved(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
this.logger.log(`Received KYCApproved event: ${message.eventId}`);
try {
await this.processKYCApproved(message.payload);
this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process KYCApproved: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.KYC_REJECTED)
async handleKYCRejected(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
this.logger.log(`Received KYCRejected event: ${message.eventId}`);
try {
await this.processKYCRejected(message.payload);
this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process KYCRejected: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN)
async handleAccountFrozen(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
this.logger.log(`Received AccountFrozen event: ${message.eventId}`);
try {
await this.processAccountFrozen(message.payload);
this.logger.log(
`Successfully processed AccountFrozen: ${message.eventId}`,
);
} catch (error) {
this.logger.error(
`Failed to process AccountFrozen: ${message.eventId}`,
error,
);
throw error;
}
}
@MessagePattern(IDENTITY_TOPICS.WALLET_BOUND)
async handleWalletBound(
@Payload() message: DomainEventMessage,
@Ctx() context: KafkaContext,
): Promise<void> {
this.logger.log(`Received WalletBound event: ${message.eventId}`);
try {
await this.processWalletBound(message.payload);
this.logger.log(`Successfully processed WalletBound: ${message.eventId}`);
} catch (error) {
this.logger.error(
`Failed to process WalletBound: ${message.eventId}`,
error,
);
throw error;
}
}
// 业务处理方法
private async processUserAccountCreated(payload: any): Promise<void> {
this.logger.debug(
`Processing UserAccountCreated: userId=${payload.userId}`,
);
// 发送欢迎通知
// 初始化用户积分
// 记录邀请关系
}
private async processDeviceAdded(payload: any): Promise<void> {
this.logger.debug(
`Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`,
);
// 发送新设备登录通知
// 安全审计记录
}
private async processPhoneBound(payload: any): Promise<void> {
this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`);
// 发送绑定成功短信
}
private async processKYCSubmitted(payload: any): Promise<void> {
this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`);
// 触发KYC审核流程
// 通知审核人员
}
private async processKYCApproved(payload: any): Promise<void> {
this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`);
// 发送审核通过通知
// 解锁高级功能
}
private async processKYCRejected(payload: any): Promise<void> {
this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`);
// 发送审核失败通知
}
private async processAccountFrozen(payload: any): Promise<void> {
this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`);
// 发送账户冻结通知
// 清除用户会话
}
private async processWalletBound(payload: any): Promise<void> {
this.logger.debug(
`Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`,
);
// 同步钱包余额
}
}

View File

@ -0,0 +1,241 @@
import {
Injectable,
Inject,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { v4 as uuidv4 } from 'uuid';
import { KAFKA_SERVICE } from './kafka.module';
export interface DomainEventMessage {
eventId: string;
eventType: string;
aggregateId: string;
aggregateType: string;
payload: any;
occurredAt: string;
version: number;
}
export const IDENTITY_TOPICS = {
USER_ACCOUNT_CREATED: 'identity.user-account.created',
DEVICE_ADDED: 'identity.device.added',
DEVICE_REMOVED: 'identity.device.removed',
PHONE_BOUND: 'identity.phone.bound',
KYC_SUBMITTED: 'identity.kyc.submitted',
KYC_APPROVED: 'identity.kyc.approved',
KYC_REJECTED: 'identity.kyc.rejected',
ACCOUNT_FROZEN: 'identity.account.frozen',
ACCOUNT_UNFROZEN: 'identity.account.unfrozen',
ACCOUNT_DEACTIVATED: 'identity.account.deactivated',
WALLET_BOUND: 'identity.wallet.bound',
};
@Injectable()
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EventPublisherService.name);
private isConnected = false;
constructor(
@Inject(KAFKA_SERVICE)
private readonly kafkaClient: ClientKafka,
) {}
async onModuleInit(): Promise<void> {
try {
await this.kafkaClient.connect();
this.isConnected = true;
this.logger.log('Kafka producer connected');
} catch (error) {
this.logger.error('Failed to connect Kafka producer', error);
// 不抛出错误允许服务在没有Kafka的情况下启动
this.isConnected = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.isConnected) {
await this.kafkaClient.close();
this.logger.log('Kafka producer disconnected');
}
}
async publish(topic: string, event: any): Promise<void> {
if (!this.isConnected) {
this.logger.warn(`Kafka not connected, skipping event publish to ${topic}`);
return;
}
const message = this.serializeEvent(event);
try {
await this.kafkaClient
.emit(topic, {
key: message.aggregateId,
value: JSON.stringify(message),
headers: {
eventType: message.eventType,
eventId: message.eventId,
occurredAt: message.occurredAt,
},
})
.toPromise();
this.logger.debug(`Event published to ${topic}: ${message.eventId}`);
} catch (error) {
this.logger.error(`Failed to publish event to ${topic}`, error);
throw error;
}
}
async publishUserAccountCreated(event: {
userId: string;
accountSequence: number;
initialDeviceId: string;
inviterSequence: number | null;
provinceCode: string;
cityCode: string;
referralCode: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.USER_ACCOUNT_CREATED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'UserAccountCreated',
payload: event,
});
}
async publishDeviceAdded(event: {
userId: string;
accountSequence: number;
deviceId: string;
deviceName: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.DEVICE_ADDED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'DeviceAdded',
payload: event,
});
}
async publishDeviceRemoved(event: {
userId: string;
deviceId: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.DEVICE_REMOVED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'DeviceRemoved',
payload: event,
});
}
async publishPhoneBound(event: {
userId: string;
phoneNumber: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.PHONE_BOUND, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'PhoneNumberBound',
payload: event,
});
}
async publishKYCSubmitted(event: {
userId: string;
realName: string;
idCardNumber: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.KYC_SUBMITTED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'KYCSubmitted',
payload: event,
});
}
async publishKYCApproved(event: { userId: string }): Promise<void> {
await this.publish(IDENTITY_TOPICS.KYC_APPROVED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'KYCApproved',
payload: event,
});
}
async publishKYCRejected(event: {
userId: string;
reason: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.KYC_REJECTED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'KYCRejected',
payload: event,
});
}
async publishAccountFrozen(event: {
userId: string;
reason: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.ACCOUNT_FROZEN, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'AccountFrozen',
payload: event,
});
}
async publishAccountUnfrozen(event: { userId: string }): Promise<void> {
await this.publish(IDENTITY_TOPICS.ACCOUNT_UNFROZEN, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'AccountUnfrozen',
payload: event,
});
}
async publishAccountDeactivated(event: { userId: string }): Promise<void> {
await this.publish(IDENTITY_TOPICS.ACCOUNT_DEACTIVATED, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'AccountDeactivated',
payload: event,
});
}
async publishWalletBound(event: {
userId: string;
chainType: string;
address: string;
}): Promise<void> {
await this.publish(IDENTITY_TOPICS.WALLET_BOUND, {
aggregateId: event.userId,
aggregateType: 'UserAccount',
eventType: 'WalletBound',
payload: event,
});
}
private serializeEvent(event: {
aggregateId: string;
aggregateType: string;
eventType: string;
payload: any;
}): DomainEventMessage {
return {
eventId: uuidv4(),
eventType: event.eventType,
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
payload: event.payload,
occurredAt: new Date().toISOString(),
version: 1,
};
}
}

View File

@ -0,0 +1,91 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventPublisherService } from './event-publisher.service';
import { DeadLetterService } from './dead-letter.service';
@Injectable()
export class EventRetryService {
private readonly logger = new Logger(EventRetryService.name);
private readonly maxRetries = 3;
private isRunning = false;
constructor(
private readonly eventPublisher: EventPublisherService,
private readonly deadLetterService: DeadLetterService,
) {}
@Cron(CronExpression.EVERY_5_MINUTES)
async retryFailedEvents(): Promise<void> {
if (this.isRunning) {
this.logger.debug('Retry job already running, skipping');
return;
}
this.isRunning = true;
this.logger.log('Starting failed events retry job');
try {
const failedEvents = await this.deadLetterService.getFailedEvents(50);
let successCount = 0;
let failCount = 0;
for (const event of failedEvents) {
if (event.retryCount >= this.maxRetries) {
this.logger.warn(
`Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`,
);
continue;
}
try {
await this.eventPublisher.publish(event.topic, {
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
eventType: event.eventType,
payload: event.payload,
});
await this.deadLetterService.markAsProcessed(event.id);
successCount++;
this.logger.log(`Successfully retried event: ${event.eventId}`);
} catch (error) {
failCount++;
await this.deadLetterService.incrementRetryCount(event.id);
this.logger.error(`Failed to retry event: ${event.eventId}`, error);
}
}
this.logger.log(
`Finished retry job: ${successCount} succeeded, ${failCount} failed`,
);
} finally {
this.isRunning = false;
}
}
async manualRetry(eventId: string): Promise<boolean> {
const events = await this.deadLetterService.getFailedEvents(1000);
const event = events.find((e) => e.eventId === eventId);
if (!event) {
this.logger.warn(`Event not found: ${eventId}`);
return false;
}
try {
await this.eventPublisher.publish(event.topic, {
aggregateId: event.aggregateId,
aggregateType: event.aggregateType,
eventType: event.eventType,
payload: event.payload,
});
await this.deadLetterService.markAsProcessed(event.id);
this.logger.log(`Manually retried event: ${eventId}`);
return true;
} catch (error) {
this.logger.error(`Failed to manually retry event: ${eventId}`, error);
return false;
}
}
}

View File

@ -0,0 +1,5 @@
export * from './kafka.module';
export * from './event-publisher.service';
export * from './event-consumer.controller';
export * from './dead-letter.service';
export * from './event-retry.service';

View File

@ -0,0 +1,46 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { EventPublisherService } from './event-publisher.service';
import { EventConsumerController } from './event-consumer.controller';
import { DeadLetterService } from './dead-letter.service';
import { EventRetryService } from './event-retry.service';
export const KAFKA_SERVICE = 'KAFKA_SERVICE';
@Global()
@Module({
imports: [
ClientsModule.registerAsync([
{
name: KAFKA_SERVICE,
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'),
brokers: configService
.get('KAFKA_BROKERS', 'localhost:9092')
.split(','),
},
consumer: {
groupId: configService.get(
'KAFKA_GROUP_ID',
'identity-service-group',
),
},
producer: {
allowAutoTopicCreation: true,
},
},
}),
inject: [ConfigService],
},
]),
],
controllers: [EventConsumerController],
providers: [EventPublisherService, DeadLetterService, EventRetryService],
exports: [EventPublisherService],
})
export class KafkaModule {}

View File

@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import {
UserAccount as PrismaUserAccount,
UserDevice as PrismaUserDevice,
WalletAddress as PrismaWalletAddress,
} from '@prisma/client';
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
import { DeviceInfo } from '@domain/value-objects/device-info.vo';
import { KYCInfo } from '@domain/value-objects/kyc-info.vo';
import { WalletAddress } from '@domain/entities/wallet-address.entity';
import { ChainType } from '@domain/enums/chain-type.enum';
import { KYCStatus } from '@domain/enums/kyc-status.enum';
import { AccountStatus } from '@domain/enums/account-status.enum';
export type UserAccountWithRelations = PrismaUserAccount & {
devices: PrismaUserDevice[];
walletAddresses: PrismaWalletAddress[];
};
@Injectable()
export class UserAccountMapper {
toDomain(raw: UserAccountWithRelations): UserAccount {
// Map devices
const devices = new Map<string, DeviceInfo>();
for (const device of raw.devices) {
devices.set(
device.deviceId,
new DeviceInfo(
device.deviceId,
device.deviceName || '未命名设备',
device.addedAt,
device.lastActiveAt,
),
);
}
// Map wallet addresses
const walletAddresses = new Map<ChainType, WalletAddress>();
for (const wallet of raw.walletAddresses) {
walletAddresses.set(
wallet.chainType as ChainType,
WalletAddress.create({
addressId: String(wallet.id),
userId: String(wallet.userId),
chainType: wallet.chainType as ChainType,
address: wallet.address,
encryptedMnemonic: wallet.encryptedMnemonic || '',
status: wallet.status as 'ACTIVE' | 'DISABLED',
boundAt: wallet.boundAt,
}),
);
}
// Map KYC info
const kycInfo =
raw.realName && raw.idCardNumber && raw.idCardFrontUrl && raw.idCardBackUrl
? KYCInfo.create({
realName: raw.realName,
idCardNumber: raw.idCardNumber,
idCardFrontUrl: raw.idCardFrontUrl,
idCardBackUrl: raw.idCardBackUrl,
})
: null;
return UserAccount.fromPersistence({
userId: String(raw.id),
accountSequence: AccountSequence.create(Number(raw.accountSequence)),
devices,
phoneNumber: raw.phoneNumber ? PhoneNumber.create(raw.phoneNumber) : null,
nickname: raw.nickname,
avatarUrl: raw.avatarUrl,
inviterSequence: raw.inviterSequence
? AccountSequence.create(Number(raw.inviterSequence))
: null,
referralCode: ReferralCode.create(raw.referralCode),
provinceCode: raw.provinceCode,
cityCode: raw.cityCode,
address: raw.address,
walletAddresses,
kycInfo,
kycStatus: raw.kycStatus as KYCStatus,
status: raw.status as AccountStatus,
registeredAt: raw.registeredAt,
lastLoginAt: raw.lastLoginAt,
updatedAt: raw.updatedAt,
});
}
toPersistence(account: UserAccount): any {
const data = account.toPersistenceData() as any;
return {
id: BigInt(data.userId),
accountSequence: BigInt(data.accountSequence),
phoneNumber: data.phoneNumber,
nickname: data.nickname,
avatarUrl: data.avatarUrl,
inviterSequence: data.inviterSequence
? BigInt(data.inviterSequence)
: null,
referralCode: data.referralCode,
provinceCode: data.provinceCode,
cityCode: data.cityCode,
address: data.address,
kycStatus: data.kycStatus,
realName: data.realName,
idCardNumber: data.idCardNumber,
idCardFrontUrl: data.idCardFrontUrl,
idCardBackUrl: data.idCardBackUrl,
status: data.status,
lastLoginAt: data.lastLoginAt,
updatedAt: data.updatedAt,
};
}
toDevicePersistence(
userId: string,
device: DeviceInfo,
): any {
return {
userId: BigInt(userId),
deviceId: device.deviceId,
deviceName: device.deviceName,
addedAt: device.addedAt,
lastActiveAt: device.lastActiveAt,
};
}
toWalletPersistence(wallet: WalletAddress): any {
return {
userId: BigInt(wallet.userId),
chainType: wallet.chainType,
address: wallet.address,
encryptedMnemonic: wallet.encryptedMnemonic,
status: wallet.status,
boundAt: wallet.boundAt,
};
}
}

View File

@ -0,0 +1,54 @@
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
}
async onModuleInit(): Promise<void> {
await this.$connect();
this.logger.log('Connected to database');
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
this.logger.log('Disconnected from database');
}
async cleanDatabase(): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new Error('Cannot clean database in production');
}
const models = Reflect.ownKeys(this).filter(
(key) => typeof key === 'string' && !key.startsWith('_') && !key.startsWith('$'),
);
for (const model of models) {
try {
await (this as any)[model]?.deleteMany?.();
} catch (error) {
// Ignore errors for non-model properties
}
}
}
}

View File

@ -0,0 +1,239 @@
import { Injectable, Logger } from '@nestjs/common';
import { IUserAccountRepository } from '@domain/repositories/user-account.repository.interface';
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
import { ChainType } from '@domain/enums/chain-type.enum';
import { WalletAddress } from '@domain/entities/wallet-address.entity';
import { PrismaService } from '../prisma/prisma.service';
import { UserAccountMapper } from '../mappers/user-account.mapper';
@Injectable()
export class UserAccountRepositoryImpl implements IUserAccountRepository {
private readonly logger = new Logger(UserAccountRepositoryImpl.name);
constructor(
private readonly prisma: PrismaService,
private readonly mapper: UserAccountMapper,
) {}
async save(account: UserAccount): Promise<void> {
const data = this.mapper.toPersistence(account);
const devices = account.getAllDevices();
await this.prisma.$transaction(async (tx) => {
// Upsert user account
await tx.userAccount.upsert({
where: { id: data.id },
create: {
...data,
registeredAt: new Date(),
},
update: {
phoneNumber: data.phoneNumber,
nickname: data.nickname,
avatarUrl: data.avatarUrl,
address: data.address,
kycStatus: data.kycStatus,
realName: data.realName,
idCardNumber: data.idCardNumber,
idCardFrontUrl: data.idCardFrontUrl,
idCardBackUrl: data.idCardBackUrl,
status: data.status,
lastLoginAt: data.lastLoginAt,
updatedAt: data.updatedAt,
},
});
// Upsert devices
for (const device of devices) {
const deviceData = this.mapper.toDevicePersistence(account.userId, device);
await tx.userDevice.upsert({
where: {
userId_deviceId: {
userId: deviceData.userId,
deviceId: deviceData.deviceId,
},
},
create: deviceData,
update: {
deviceName: deviceData.deviceName,
lastActiveAt: deviceData.lastActiveAt,
},
});
}
});
this.logger.debug(`Saved user account: ${account.userId}`);
}
async saveWallets(userId: string, wallets: WalletAddress[]): Promise<void> {
await this.prisma.$transaction(
wallets.map((wallet) =>
this.prisma.walletAddress.create({
data: this.mapper.toWalletPersistence(wallet),
}),
),
);
this.logger.debug(`Saved ${wallets.length} wallets for user: ${userId}`);
}
async findById(userId: string): Promise<UserAccount | null> {
const raw = await this.prisma.userAccount.findUnique({
where: { id: BigInt(userId) },
include: {
devices: true,
walletAddresses: true,
},
});
return raw ? this.mapper.toDomain(raw) : null;
}
async findByAccountSequence(
sequence: AccountSequence,
): Promise<UserAccount | null> {
const raw = await this.prisma.userAccount.findUnique({
where: { accountSequence: BigInt(sequence.value) },
include: {
devices: true,
walletAddresses: true,
},
});
return raw ? this.mapper.toDomain(raw) : null;
}
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
const device = await this.prisma.userDevice.findFirst({
where: { deviceId },
include: {
user: {
include: {
devices: true,
walletAddresses: true,
},
},
},
});
return device ? this.mapper.toDomain(device.user) : null;
}
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> {
const raw = await this.prisma.userAccount.findUnique({
where: { phoneNumber: phoneNumber.value },
include: {
devices: true,
walletAddresses: true,
},
});
return raw ? this.mapper.toDomain(raw) : null;
}
async findByReferralCode(
referralCode: ReferralCode,
): Promise<UserAccount | null> {
const raw = await this.prisma.userAccount.findUnique({
where: { referralCode: referralCode.value },
include: {
devices: true,
walletAddresses: true,
},
});
return raw ? this.mapper.toDomain(raw) : null;
}
async findByWalletAddress(
chainType: ChainType,
address: string,
): Promise<UserAccount | null> {
const wallet = await this.prisma.walletAddress.findUnique({
where: {
chainType_address: {
chainType,
address,
},
},
include: {
user: {
include: {
devices: true,
walletAddresses: true,
},
},
},
});
return wallet ? this.mapper.toDomain(wallet.user) : null;
}
async getNextAccountSequence(): Promise<AccountSequence> {
// 使用行级锁获取下一个序列号
const result = await this.prisma.$queryRaw<{ current_sequence: bigint }[]>`
SELECT current_sequence FROM account_sequence_generator
WHERE id = 1
FOR UPDATE
`;
let nextSequence: number;
if (result.length === 0) {
// 初始化序列号生成器
await this.prisma.$executeRaw`
INSERT INTO account_sequence_generator (id, current_sequence)
VALUES (1, 1)
ON CONFLICT (id) DO UPDATE SET current_sequence = account_sequence_generator.current_sequence + 1
`;
nextSequence = 1;
} else {
// 更新并获取下一个序列号
await this.prisma.$executeRaw`
UPDATE account_sequence_generator
SET current_sequence = current_sequence + 1, updated_at = NOW()
WHERE id = 1
`;
nextSequence = Number(result[0].current_sequence) + 1;
}
return AccountSequence.create(nextSequence);
}
async existsByDeviceId(deviceId: string): Promise<boolean> {
const count = await this.prisma.userDevice.count({
where: { deviceId },
});
return count > 0;
}
async existsByPhoneNumber(phoneNumber: PhoneNumber): Promise<boolean> {
const count = await this.prisma.userAccount.count({
where: { phoneNumber: phoneNumber.value },
});
return count > 0;
}
async existsByReferralCode(referralCode: ReferralCode): Promise<boolean> {
const count = await this.prisma.userAccount.count({
where: { referralCode: referralCode.value },
});
return count > 0;
}
async removeDevice(userId: string, deviceId: string): Promise<void> {
await this.prisma.userDevice.delete({
where: {
userId_deviceId: {
userId: BigInt(userId),
deviceId,
},
},
});
this.logger.debug(`Removed device ${deviceId} from user ${userId}`);
}
}

View File

@ -0,0 +1,33 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: REDIS_CLIENT,
useFactory: (configService: ConfigService) => {
return new Redis({
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get('REDIS_PORT', 6379),
password: configService.get('REDIS_PASSWORD') || undefined,
db: configService.get('REDIS_DB', 0),
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
},
inject: [ConfigService],
},
RedisService,
],
exports: [RedisService, REDIS_CLIENT],
})
export class RedisModule {}

View File

@ -0,0 +1,135 @@
import { Injectable, Inject, Logger, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS_CLIENT } from './redis.module';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {
this.client.on('connect', () => {
this.logger.log('Redis connected');
});
this.client.on('error', (error) => {
this.logger.error('Redis error', error);
});
}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
this.logger.log('Redis disconnected');
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async get(key: string): Promise<string | null> {
return await this.client.get(key);
}
async delete(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(key);
return result === 1;
}
async setJSON<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
async getJSON<T>(key: string): Promise<T | null> {
const value = await this.get(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
async increment(key: string): Promise<number> {
return await this.client.incr(key);
}
async decrement(key: string): Promise<number> {
return await this.client.decr(key);
}
async expire(key: string, ttlSeconds: number): Promise<void> {
await this.client.expire(key, ttlSeconds);
}
async ttl(key: string): Promise<number> {
return await this.client.ttl(key);
}
async keys(pattern: string): Promise<string[]> {
return await this.client.keys(pattern);
}
async deleteByPattern(pattern: string): Promise<void> {
const keys = await this.keys(pattern);
if (keys.length > 0) {
await this.client.del(...keys);
}
}
// SMS验证码相关方法
async setSmsCode(
phoneNumber: string,
type: string,
code: string,
ttlSeconds: number = 300,
): Promise<void> {
const key = `sms:${type}:${phoneNumber}`;
await this.set(key, code, ttlSeconds);
}
async getSmsCode(phoneNumber: string, type: string): Promise<string | null> {
const key = `sms:${type}:${phoneNumber}`;
return await this.get(key);
}
async deleteSmsCode(phoneNumber: string, type: string): Promise<void> {
const key = `sms:${type}:${phoneNumber}`;
await this.delete(key);
}
// Token黑名单相关方法
async addToBlacklist(
tokenHash: string,
ttlSeconds: number,
): Promise<void> {
const key = `blacklist:${tokenHash}`;
await this.set(key, '1', ttlSeconds);
}
async isBlacklisted(tokenHash: string): Promise<boolean> {
const key = `blacklist:${tokenHash}`;
return await this.exists(key);
}
// 分布式锁
async acquireLock(
lockKey: string,
ttlSeconds: number = 30,
): Promise<boolean> {
const key = `lock:${lockKey}`;
const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');
return result === 'OK';
}
async releaseLock(lockKey: string): Promise<void> {
const key = `lock:${lockKey}`;
await this.delete(key);
}
}

View File

@ -0,0 +1,88 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// 全局前缀
const apiPrefix = configService.get('API_PREFIX', 'api/v1');
app.setGlobalPrefix(apiPrefix);
// CORS
app.enableCors({
origin: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
});
// 全局验证管道
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// 全局异常过滤器
app.useGlobalFilters(new GlobalExceptionFilter());
// Swagger文档
if (configService.get('NODE_ENV') !== 'production') {
const config = new DocumentBuilder()
.setTitle('Identity Service API')
.setDescription('RWA Identity & User Context Microservice')
.setVersion('2.0.0')
.addBearerAuth()
.addTag('用户管理', '用户账户相关接口')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
logger.log('Swagger documentation available at /api/docs');
}
// 连接Kafka微服务
const kafkaBrokers = configService.get('KAFKA_BROKERS', 'localhost:9092');
try {
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'),
brokers: kafkaBrokers.split(','),
},
consumer: {
groupId: configService.get('KAFKA_GROUP_ID', 'identity-service-group'),
},
},
});
// 启动所有微服务
await app.startAllMicroservices();
logger.log('Kafka microservice connected');
} catch (error) {
logger.warn(`Kafka connection failed: ${error.message}. Service will continue without Kafka.`);
}
// 启动HTTP服务
const port = configService.get('PORT', 3000);
await app.listen(port);
logger.log(`🚀 Identity Service is running on: http://localhost:${port}`);
logger.log(`📚 API Prefix: ${apiPrefix}`);
logger.log(`🌍 Environment: ${configService.get('NODE_ENV', 'development')}`);
}
bootstrap();

View File

@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
export const CurrentDeviceId = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user?.deviceId;
},
);
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,7 @@
export class ApplicationException extends Error {
constructor(message: string) {
super(message);
this.name = 'ApplicationException';
Object.setPrototypeOf(this, ApplicationException.prototype);
}
}

View File

@ -0,0 +1,7 @@
export class DomainException extends Error {
constructor(message: string) {
super(message);
this.name = 'DomainException';
Object.setPrototypeOf(this, DomainException.prototype);
}
}

View File

@ -0,0 +1,2 @@
export * from './domain.exception';
export * from './application.exception';

View File

@ -0,0 +1,67 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
HttpException,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { DomainException } from '../exceptions/domain.exception';
import { ApplicationException } from '../exceptions/application.exception';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
let status: number;
let message: string;
let error: string;
if (exception instanceof DomainException) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
error = 'DomainException';
} else if (exception instanceof ApplicationException) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
error = 'ApplicationException';
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
error = exception.name;
} else if (exception instanceof Error) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '服务器内部错误';
error = 'InternalServerError';
// 记录未知错误
this.logger.error(
`Unhandled exception: ${exception.message}`,
exception.stack,
);
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '未知错误';
error = 'UnknownError';
}
response.status(status).json({
success: false,
statusCode: status,
message,
error,
path: request.url,
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1,36 @@
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
export const IS_PUBLIC_KEY = 'isPublic';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('未授权访问');
}
return user;
}
}

View File

@ -0,0 +1,36 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
userId: string;
accountSequence: number;
deviceId: string;
type: 'access' | 'refresh';
iat: number;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET', 'default-secret'),
});
}
async validate(payload: JwtPayload) {
if (payload.type !== 'access') {
throw new UnauthorizedException('无效的Token类型');
}
return {
userId: payload.userId,
accountSequence: payload.accountSequence,
deviceId: payload.deviceId,
};
}
}

View File

@ -0,0 +1,17 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@app/(.*)$": "<rootDir>/src/$1",
"^@domain/(.*)$": "<rootDir>/src/domain/$1",
"^@application/(.*)$": "<rootDir>/src/application/$1",
"^@infrastructure/(.*)$": "<rootDir>/src/infrastructure/$1",
"^@api/(.*)$": "<rootDir>/src/api/$1",
"^@shared/(.*)$": "<rootDir>/src/shared/$1"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -0,0 +1,32 @@
{
"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": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@app/*": ["src/*"],
"@domain/*": ["src/domain/*"],
"@application/*": ["src/application/*"],
"@infrastructure/*": ["src/infrastructure/*"],
"@api/*": ["src/api/*"],
"@shared/*": ["src/shared/*"],
"@config/*": ["src/config/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}