This commit is contained in:
hailin 2025-11-24 07:47:29 +00:00
parent e2055483db
commit 4b03c422ea
96 changed files with 5052 additions and 0 deletions

View File

@ -0,0 +1,29 @@
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/rwa_identity?schema=public"
# JWT
JWT_SECRET="dev-jwt-secret-key"
JWT_ACCESS_EXPIRES_IN="2h"
JWT_REFRESH_EXPIRES_IN="30d"
# Redis
REDIS_HOST="localhost"
REDIS_PORT=6379
REDIS_PASSWORD=""
REDIS_DB=0
# Kafka
KAFKA_BROKERS="localhost:9092"
KAFKA_CLIENT_ID="identity-service"
KAFKA_GROUP_ID="identity-service-group"
# SMS Service
SMS_API_URL="https://sms-api.example.com"
SMS_API_KEY="dev-sms-api-key"
# App
APP_PORT=3000
APP_ENV="development"
# Blockchain Encryption
WALLET_ENCRYPTION_SALT="dev-wallet-salt"

View File

@ -0,0 +1,29 @@
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/rwa_identity?schema=public"
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_ACCESS_EXPIRES_IN="2h"
JWT_REFRESH_EXPIRES_IN="30d"
# Redis
REDIS_HOST="localhost"
REDIS_PORT=6379
REDIS_PASSWORD=""
REDIS_DB=0
# Kafka
KAFKA_BROKERS="localhost:9092"
KAFKA_CLIENT_ID="identity-service"
KAFKA_GROUP_ID="identity-service-group"
# SMS Service
SMS_API_URL="https://sms-api.example.com"
SMS_API_KEY="your-sms-api-key"
# App
APP_PORT=3000
APP_ENV="development"
# Blockchain Encryption
WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"

View File

@ -0,0 +1,29 @@
# Database
DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}?schema=public"
# JWT
JWT_SECRET="${JWT_SECRET}"
JWT_ACCESS_EXPIRES_IN="2h"
JWT_REFRESH_EXPIRES_IN="30d"
# Redis
REDIS_HOST="${REDIS_HOST}"
REDIS_PORT=6379
REDIS_PASSWORD="${REDIS_PASSWORD}"
REDIS_DB=0
# Kafka
KAFKA_BROKERS="${KAFKA_BROKERS}"
KAFKA_CLIENT_ID="identity-service"
KAFKA_GROUP_ID="identity-service-group"
# SMS Service
SMS_API_URL="${SMS_API_URL}"
SMS_API_KEY="${SMS_API_KEY}"
# App
APP_PORT=3000
APP_ENV="production"
# Blockchain Encryption
WALLET_ENCRYPTION_SALT="${WALLET_ENCRYPTION_SALT}"

View File

@ -0,0 +1,30 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY src/infrastructure/persistence/prisma ./src/infrastructure/persistence/prisma/
RUN npm ci
COPY . .
RUN npm run prisma:generate
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/infrastructure/persistence/prisma ./src/infrastructure/persistence/prisma
COPY --from=builder /app/package*.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "run", "start:prod"]

View File

@ -0,0 +1,124 @@
# Identity Service
RWA用户身份上下文微服务 - 基于DDD架构的NestJS实现
## 技术栈
- **框架**: NestJS + TypeScript
- **ORM**: Prisma
- **消息队列**: Kafka
- **缓存**: Redis (ioredis)
- **区块链**: ethers.js + @scure/bip32 + bech32
## 项目结构
```
src/
├── api/ # 表现层
│ ├── controllers/ # 控制器
│ └── dto/ # 请求/响应DTO
├── application/ # 应用层
│ ├── commands/ # 命令对象
│ └── services/ # 应用服务
├── domain/ # 领域层
│ ├── aggregates/ # 聚合根
│ ├── entities/ # 实体
│ ├── events/ # 领域事件
│ ├── repositories/ # 仓储接口
│ ├── services/ # 领域服务
│ └── value-objects/ # 值对象
├── infrastructure/ # 基础设施层
│ ├── persistence/ # 持久化
│ ├── redis/ # Redis服务
│ ├── kafka/ # Kafka事件发布
│ └── external/ # 外部服务
├── shared/ # 共享层
│ ├── decorators/ # 装饰器
│ ├── guards/ # 守卫
│ ├── filters/ # 过滤器
│ └── exceptions/ # 异常类
└── config/ # 配置
```
## 核心功能
- ✅ 用户账户自动创建(首次打开APP)
- ✅ 多设备管理与授权(最多5个设备)
- ✅ 三链钱包地址生成(KAVA/DST/BSC)
- ✅ 助记词生成与加密存储
- ✅ 序列号+助记词恢复账户
- ✅ 序列号+手机号恢复账户
- ✅ KYC实名认证
- ✅ 推荐码生成与验证
- ✅ Token自动刷新机制
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env 文件配置数据库等信息
```
### 3. 初始化数据库
```bash
npm run prisma:generate
npm run prisma:migrate
```
### 4. 启动服务
```bash
# 开发模式
npm run start:dev
# 生产模式
npm run build
npm run start:prod
```
### 5. Docker部署
```bash
docker-compose up -d
```
## API文档
启动服务后访问: http://localhost:3000/api/docs
## 主要API
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /user/auto-create | 自动创建账户 |
| POST | /user/recover-by-mnemonic | 助记词恢复 |
| POST | /user/recover-by-phone | 手机号恢复 |
| POST | /user/auto-login | 自动登录 |
| GET | /user/my-profile | 我的资料 |
| GET | /user/my-devices | 我的设备 |
| POST | /user/bind-phone | 绑定手机号 |
| POST | /user/submit-kyc | 提交KYC |
## 领域不变式
1. 手机号在系统内唯一(可为空)
2. 账户序列号全局唯一且递增
3. 每个账户最多5个设备同时登录
4. KYC认证通过后身份信息不可修改
5. 每个区块链地址只能绑定一个账户
6. 推荐人序列号一旦设置终生不可修改
7. 助记词必须加密存储,只在创建时返回一次
8. 三条链的钱包地址必须从同一个助记词派生
## License
Proprietary

View File

@ -0,0 +1,7 @@
-- ============================================
-- Identity Context 数据库初始化 (PostgreSQL)
-- ============================================
-- 初始化账户序列号生成器
INSERT INTO account_sequence_generator (id, current_sequence) VALUES (1, 0)
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,69 @@
version: '3.8'
services:
identity-service:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@postgres:5432/rwa_identity?schema=public
- JWT_SECRET=your-super-secret-jwt-key-change-in-production
- JWT_ACCESS_EXPIRES_IN=2h
- JWT_REFRESH_EXPIRES_IN=30d
- REDIS_HOST=redis
- REDIS_PORT=6379
- KAFKA_BROKERS=kafka:9092
- APP_PORT=3000
- APP_ENV=production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
kafka:
condition: service_started
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=rwa_identity
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
volumes:
postgres_data:
redis_data:

View File

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true
}
}
]
}
}

View File

@ -0,0 +1,87 @@
{
"name": "identity-service",
"version": "1.0.0",
"description": "RWA Identity & User Context Service",
"author": "RWA Team",
"private": true,
"license": "UNLICENSED",
"prisma": {
"schema": "src/infrastructure/persistence/prisma/schema.prisma"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"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"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.2.1",
"bech32": "^2.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.7.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UserAccountController } from './controllers/user-account.controller';
import { AuthController } from './controllers/auth.controller';
import { ApplicationModule } from '@/application/application.module';
@Module({
imports: [ApplicationModule],
controllers: [UserAccountController, AuthController],
})
export class ApiModule {}

View File

@ -0,0 +1,19 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service';
import { Public } from '@/shared/guards/jwt-auth.guard';
import { AutoLoginCommand } from '@/application/commands';
import { AutoLoginDto } from '@/api/dto';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly userService: UserApplicationService) {}
@Public()
@Post('refresh')
@ApiOperation({ summary: 'Token刷新' })
async refresh(@Body() dto: AutoLoginDto) {
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
}
}

View File

@ -0,0 +1,169 @@
import { Controller, Post, Get, Put, Body, Param, UseGuards, Headers } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service';
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
} from '@/application/commands';
import {
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
BindWalletDto, SubmitKYCDto, RemoveDeviceDto,
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
UserProfileResponseDto, DeviceResponseDto,
} from '@/api/dto';
@ApiTags('User')
@Controller('user')
@UseGuards(JwtAuthGuard)
export class UserAccountController {
constructor(private readonly userService: UserApplicationService) {}
@Public()
@Post('auto-create')
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
async autoCreate(@Body() dto: AutoCreateAccountDto) {
return this.userService.autoCreateAccount(
new AutoCreateAccountCommand(
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
dto.provinceCode, dto.cityCode,
),
);
}
@Public()
@Post('recover-by-mnemonic')
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
return this.userService.recoverByMnemonic(
new RecoverByMnemonicCommand(
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName,
),
);
}
@Public()
@Post('recover-by-phone')
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
return this.userService.recoverByPhone(
new RecoverByPhoneCommand(
dto.accountSequence, dto.phoneNumber, dto.smsCode,
dto.newDeviceId, dto.deviceName,
),
);
}
@Public()
@Post('auto-login')
@ApiOperation({ summary: '自动登录(Token刷新)' })
@ApiResponse({ status: 200, type: LoginResponseDto })
async autoLogin(@Body() dto: AutoLoginDto) {
return this.userService.autoLogin(
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
);
}
@Public()
@Post('send-sms-code')
@ApiOperation({ summary: '发送短信验证码' })
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type));
return { message: '验证码已发送' };
}
@Public()
@Post('register')
@ApiOperation({ summary: '用户注册(手机号)' })
@ApiResponse({ status: 200, type: LoginResponseDto })
async register(@Body() dto: RegisterDto) {
return this.userService.register(
new RegisterCommand(
dto.phoneNumber, dto.smsCode, dto.deviceId,
dto.provinceCode, dto.cityCode, dto.deviceName, dto.inviterReferralCode,
),
);
}
@Public()
@Post('login')
@ApiOperation({ summary: '用户登录(手机号)' })
@ApiResponse({ status: 200, type: LoginResponseDto })
async login(@Body() dto: LoginDto) {
return this.userService.login(
new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId),
);
}
@Post('bind-phone')
@ApiBearerAuth()
@ApiOperation({ summary: '绑定手机号' })
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
await this.userService.bindPhoneNumber(
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
);
return { message: '绑定成功' };
}
@Get('my-profile')
@ApiBearerAuth()
@ApiOperation({ summary: '查询我的资料' })
@ApiResponse({ status: 200, type: UserProfileResponseDto })
async getMyProfile(@CurrentUser() user: CurrentUserData) {
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
}
@Put('update-profile')
@ApiBearerAuth()
@ApiOperation({ summary: '更新用户资料' })
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
await this.userService.updateProfile(
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl, dto.address),
);
return { message: '更新成功' };
}
@Post('submit-kyc')
@ApiBearerAuth()
@ApiOperation({ summary: '提交KYC认证' })
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
await this.userService.submitKYC(
new SubmitKYCCommand(
user.userId, dto.realName, dto.idCardNumber,
dto.idCardFrontUrl, dto.idCardBackUrl,
),
);
return { message: '提交成功' };
}
@Get('my-devices')
@ApiBearerAuth()
@ApiOperation({ summary: '查看我的设备列表' })
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
async getMyDevices(@CurrentUser() user: CurrentUserData) {
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId));
}
@Post('remove-device')
@ApiBearerAuth()
@ApiOperation({ summary: '移除设备' })
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
await this.userService.removeDevice(
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
);
return { message: '移除成功' };
}
@Public()
@Get('by-referral-code/:code')
@ApiOperation({ summary: '根据推荐码查询用户' })
async getByReferralCode(@Param('code') code: string) {
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
}
}

View File

@ -0,0 +1,182 @@
// Request DTOs
export * from './request';
// Response DTOs
export * from './response';
// 其他通用DTOs
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AutoLoginDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
refreshToken: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
deviceId: string;
}
export class SendSmsCodeDto {
@ApiProperty({ example: '13800138000' })
@IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string;
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
}
export class RegisterDto {
@ApiProperty({ example: '13800138000' })
@IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string;
@ApiProperty({ example: '123456' })
@IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
deviceId: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
provinceCode: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
cityCode: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
deviceName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
inviterReferralCode?: string;
}
export class LoginDto {
@ApiProperty({ example: '13800138000' })
@IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string;
@ApiProperty({ example: '123456' })
@IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
deviceId: string;
}
export class UpdateProfileDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
}
export class BindWalletDto {
@ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] })
@IsEnum(['KAVA', 'DST', 'BSC'])
chainType: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
address: string;
}
export class RemoveDeviceDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
deviceId: string;
}
// Response DTOs
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;
}
export class RecoverAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiProperty()
nickname: string;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty()
referralCode: string;
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
}
export class LoginResponseDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
}

View File

@ -0,0 +1,30 @@
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AutoCreateAccountDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsString()
@IsNotEmpty()
deviceId: string;
@ApiPropertyOptional({ example: 'iPhone 15 Pro' })
@IsOptional()
@IsString()
deviceName?: string;
@ApiPropertyOptional({ example: 'ABC123' })
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6}$/, { message: '推荐码格式错误' })
inviterReferralCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
provinceCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cityCode?: string;
}

View File

@ -0,0 +1,14 @@
import { IsString, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class BindPhoneDto {
@ApiProperty({ example: '13800138000' })
@IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string;
@ApiProperty({ example: '123456' })
@IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string;
}

View File

@ -0,0 +1,5 @@
export * from './auto-create-account.dto';
export * from './recover-by-mnemonic.dto';
export * from './recover-by-phone.dto';
export * from './bind-phone.dto';
export * from './submit-kyc.dto';

View File

@ -0,0 +1,23 @@
import { IsString, IsOptional, IsNotEmpty, IsNumber } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecoverByMnemonicDto {
@ApiProperty({ example: 10001 })
@IsNumber()
accountSequence: number;
@ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' })
@IsString()
@IsNotEmpty()
mnemonic: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
newDeviceId: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
deviceName?: string;
}

View File

@ -0,0 +1,28 @@
import { IsString, IsOptional, IsNotEmpty, IsNumber, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RecoverByPhoneDto {
@ApiProperty({ example: 10001 })
@IsNumber()
accountSequence: number;
@ApiProperty({ example: '13800138000' })
@IsString()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
phoneNumber: string;
@ApiProperty({ example: '123456' })
@IsString()
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
smsCode: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
newDeviceId: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
deviceName?: string;
}

View File

@ -0,0 +1,24 @@
import { IsString, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SubmitKycDto {
@ApiProperty({ example: '张三' })
@IsString()
@IsNotEmpty()
realName: string;
@ApiProperty({ example: '110101199001011234' })
@IsString()
@Matches(/^[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]$/, { message: '身份证号格式错误' })
idCardNumber: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
idCardFrontUrl: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
idCardBackUrl: string;
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class DeviceDto {
@ApiProperty()
deviceId: string;
@ApiProperty()
deviceName: string;
@ApiProperty()
addedAt: Date;
@ApiProperty()
lastActiveAt: Date;
@ApiProperty()
isCurrent: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './user-profile.dto';
export * from './device.dto';

View File

@ -0,0 +1,64 @@
import { ApiProperty } from '@nestjs/swagger';
export class WalletAddressDto {
@ApiProperty()
chainType: string;
@ApiProperty()
address: string;
}
export class KycInfoDto {
@ApiProperty()
realName: string;
@ApiProperty()
idCardNumber: string;
}
export class UserProfileDto {
@ApiProperty()
userId: string;
@ApiProperty()
accountSequence: number;
@ApiProperty({ nullable: true })
phoneNumber: string | null;
@ApiProperty()
nickname: string;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty()
referralCode: string;
@ApiProperty()
province: string;
@ApiProperty()
city: string;
@ApiProperty({ nullable: true })
address: string | null;
@ApiProperty({ type: [WalletAddressDto] })
walletAddresses: WalletAddressDto[];
@ApiProperty()
kycStatus: string;
@ApiProperty({ type: KycInfoDto, nullable: true })
kycInfo: KycInfoDto | null;
@ApiProperty()
status: string;
@ApiProperty()
registeredAt: Date;
@ApiProperty({ nullable: true })
lastLoginAt: Date | null;
}

View File

@ -0,0 +1,47 @@
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator';
@ValidatorConstraint({ name: 'isChinesePhone', async: false })
export class IsChinesePhoneConstraint implements ValidatorConstraintInterface {
validate(phone: string, args: ValidationArguments): boolean {
return /^1[3-9]\d{9}$/.test(phone);
}
defaultMessage(args: ValidationArguments): string {
return '手机号格式错误';
}
}
export function IsChinesePhone(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsChinesePhoneConstraint,
});
};
}
@ValidatorConstraint({ name: 'isChineseIdCard', async: false })
export class IsChineseIdCardConstraint implements ValidatorConstraintInterface {
validate(idCard: string, args: ValidationArguments): boolean {
return /^[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]$/.test(idCard);
}
defaultMessage(args: ValidationArguments): string {
return '身份证号格式错误';
}
}
export function IsChineseIdCard(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsChineseIdCardConstraint,
});
};
}

View File

@ -0,0 +1,100 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
// Config
import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config';
// Controllers
import { UserAccountController } from '@/api/controllers/user-account.controller';
// Application Services
import { UserApplicationService } from '@/application/services/user-application.service';
import { TokenService } from '@/application/services/token.service';
// Domain Services
import {
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
} from '@/domain/services';
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
// Infrastructure
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
// Shared
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
// ============ Infrastructure Module ============
@Global()
@Module({
providers: [PrismaService, RedisService, EventPublisherService, SmsService],
exports: [PrismaService, RedisService, EventPublisherService, SmsService],
})
export class InfrastructureModule {}
// ============ Domain Module ============
@Module({
imports: [InfrastructureModule],
providers: [
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
],
exports: [
USER_ACCOUNT_REPOSITORY,
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
],
})
export class DomainModule {}
// ============ Application Module ============
@Module({
imports: [DomainModule, InfrastructureModule],
providers: [UserApplicationService, TokenService],
exports: [UserApplicationService, TokenService],
})
export class ApplicationModule {}
// ============ API Module ============
@Module({
imports: [ApplicationModule],
controllers: [UserAccountController],
})
export class ApiModule {}
// ============ App Module ============
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
}),
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
}),
}),
InfrastructureModule,
DomainModule,
ApplicationModule,
ApiModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}

View File

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { UserApplicationService } from './services/user-application.service';
import { TokenService } from './services/token.service';
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
import { DomainModule } from '@/domain/domain.module';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
@Module({
imports: [DomainModule, InfrastructureModule],
providers: [
UserApplicationService,
TokenService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,
BindPhoneHandler,
GetMyProfileHandler,
GetMyDevicesHandler,
],
exports: [
UserApplicationService,
TokenService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,
BindPhoneHandler,
GetMyProfileHandler,
GetMyDevicesHandler,
],
})
export class ApplicationModule {}

View File

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

View File

@ -0,0 +1,80 @@
import { Injectable, Inject } from '@nestjs/common';
import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services';
import { ReferralCode, AccountSequence, ProvinceCode, CityCode, ChainType } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AutoCreateAccountResult } from '../index';
@Injectable()
export class AutoCreateAccountHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly sequenceGenerator: AccountSequenceGeneratorService,
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
const inviter = await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence;
}
const accountSequence = await this.sequenceGenerator.generateNext();
const account = UserAccount.createAutomatic({
accountSequence,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
inviterSequence,
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
city: CityCode.create(command.cityCode || 'DEFAULT'),
});
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
userId: account.userId,
deviceId: command.deviceId,
});
account.bindMultipleWalletAddresses(wallets);
await this.userRepository.save(account);
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: mnemonic.value,
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,
bsc: wallets.get(ChainType.BSC)!.address,
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
}

View File

@ -0,0 +1,7 @@
export class BindPhoneCommand {
constructor(
public readonly userId: string,
public readonly phoneNumber: string,
public readonly smsCode: string,
) {}
}

View File

@ -0,0 +1,37 @@
import { Injectable, Inject } from '@nestjs/common';
import { BindPhoneCommand } from './bind-phone.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserValidatorService } from '@/domain/services';
import { UserId, PhoneNumber } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
@Injectable()
export class BindPhoneHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly validatorService: UserValidatorService,
private readonly redisService: RedisService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: BindPhoneCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!);
account.bindPhoneNumber(phoneNumber);
await this.userRepository.save(account);
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
}

View File

@ -0,0 +1,206 @@
// ============ Commands ============
export class AutoCreateAccountCommand {
constructor(
public readonly deviceId: string,
public readonly deviceName?: string,
public readonly inviterReferralCode?: string,
public readonly provinceCode?: string,
public readonly cityCode?: string,
) {}
}
export class RecoverByMnemonicCommand {
constructor(
public readonly accountSequence: number,
public readonly mnemonic: string,
public readonly newDeviceId: string,
public readonly deviceName?: string,
) {}
}
export class RecoverByPhoneCommand {
constructor(
public readonly accountSequence: number,
public readonly phoneNumber: string,
public readonly smsCode: string,
public readonly newDeviceId: string,
public readonly deviceName?: string,
) {}
}
export class AutoLoginCommand {
constructor(
public readonly refreshToken: string,
public readonly deviceId: string,
) {}
}
export class RegisterCommand {
constructor(
public readonly phoneNumber: string,
public readonly smsCode: string,
public readonly deviceId: string,
public readonly provinceCode: string,
public readonly cityCode: string,
public readonly deviceName?: string,
public readonly inviterReferralCode?: string,
) {}
}
export class LoginCommand {
constructor(
public readonly phoneNumber: string,
public readonly smsCode: string,
public readonly deviceId: string,
) {}
}
export class BindPhoneNumberCommand {
constructor(
public readonly userId: string,
public readonly phoneNumber: string,
public readonly smsCode: string,
) {}
}
export class UpdateProfileCommand {
constructor(
public readonly userId: string,
public readonly nickname?: string,
public readonly avatarUrl?: string,
public readonly address?: string,
) {}
}
export class BindWalletAddressCommand {
constructor(
public readonly userId: string,
public readonly chainType: string,
public readonly address: string,
) {}
}
export class SubmitKYCCommand {
constructor(
public readonly userId: string,
public readonly realName: string,
public readonly idCardNumber: string,
public readonly idCardFrontUrl: string,
public readonly idCardBackUrl: string,
) {}
}
export class ReviewKYCCommand {
constructor(
public readonly userId: string,
public readonly approved: boolean,
public readonly reason?: string,
) {}
}
export class RemoveDeviceCommand {
constructor(
public readonly userId: string,
public readonly currentDeviceId: string,
public readonly deviceIdToRemove: string,
) {}
}
export class SendSmsCodeCommand {
constructor(
public readonly phoneNumber: string,
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
) {}
}
// ============ Queries ============
export class GetMyProfileQuery {
constructor(public readonly userId: string) {}
}
export class GetMyDevicesQuery {
constructor(
public readonly userId: string,
public readonly currentDeviceId: string,
) {}
}
export class GetUserByReferralCodeQuery {
constructor(public readonly referralCode: string) {}
}
// ============ Results ============
export interface AutoCreateAccountResult {
userId: string;
accountSequence: number;
referralCode: string;
mnemonic: string;
walletAddresses: { kava: string; dst: string; bsc: string };
accessToken: string;
refreshToken: string;
}
export interface RecoverAccountResult {
userId: string;
accountSequence: number;
nickname: string;
avatarUrl: string | null;
referralCode: string;
accessToken: string;
refreshToken: string;
}
export interface AutoLoginResult {
userId: string;
accountSequence: number;
accessToken: string;
refreshToken: string;
}
export interface RegisterResult {
userId: string;
accountSequence: number;
referralCode: string;
accessToken: string;
refreshToken: string;
}
export interface LoginResult {
userId: string;
accountSequence: number;
accessToken: string;
refreshToken: string;
}
export interface UserProfileDTO {
userId: string;
accountSequence: number;
phoneNumber: string | null;
nickname: string;
avatarUrl: string | null;
referralCode: string;
province: string;
city: string;
address: string | null;
walletAddresses: Array<{ chainType: string; address: string }>;
kycStatus: string;
kycInfo: { realName: string; idCardNumber: string } | null;
status: string;
registeredAt: Date;
lastLoginAt: Date | null;
}
export interface DeviceDTO {
deviceId: string;
deviceName: string;
addedAt: Date;
lastActiveAt: Date;
isCurrent: boolean;
}
export interface UserBriefDTO {
userId: string;
accountSequence: number;
nickname: string;
avatarUrl: string | null;
}

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,
) {}
}

View File

@ -0,0 +1,62 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { WalletGeneratorService } from '@/domain/services';
import { AccountSequence, ChainType, Mnemonic } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { RecoverAccountResult } from '../index';
@Injectable()
export class RecoverByMnemonicHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
const mnemonic = Mnemonic.create(command.mnemonic);
const wallets = this.walletGenerator.recoverWalletSystem({
userId: account.userId,
mnemonic,
deviceId: command.newDeviceId,
});
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) {
throw new ApplicationError('助记词错误');
}
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin();
await this.userRepository.save(account);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
}

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,
) {}
}

View File

@ -0,0 +1,58 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecoverByPhoneCommand } from './recover-by-phone.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { RecoverAccountResult } from '../index';
@Injectable()
export class RecoverByPhoneHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly tokenService: TokenService,
private readonly redisService: RedisService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
const phoneNumber = PhoneNumber.create(command.phoneNumber);
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin();
await this.userRepository.save(account);
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, Inject } from '@nestjs/common';
import { GetMyDevicesQuery } from './get-my-devices.query';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserId } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { DeviceDTO } from '@/application/commands';
@Injectable()
export class GetMyDevicesHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
) {}
async execute(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
const account = await this.userRepository.findById(UserId.create(query.userId));
if (!account) throw new ApplicationError('用户不存在');
return account.getAllDevices().map((device) => ({
deviceId: device.deviceId,
deviceName: device.deviceName,
addedAt: device.addedAt,
lastActiveAt: device.lastActiveAt,
isCurrent: device.deviceId === query.currentDeviceId,
}));
}
}

View File

@ -0,0 +1,6 @@
export class GetMyDevicesQuery {
constructor(
public readonly userId: string,
public readonly currentDeviceId: string,
) {}
}

View File

@ -0,0 +1,46 @@
import { Injectable, Inject } from '@nestjs/common';
import { GetMyProfileQuery } from './get-my-profile.query';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { UserId } from '@/domain/value-objects';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { UserProfileDTO } from '@/application/commands';
@Injectable()
export class GetMyProfileHandler {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
) {}
async execute(query: GetMyProfileQuery): Promise<UserProfileDTO> {
const account = await this.userRepository.findById(UserId.create(query.userId));
if (!account) throw new ApplicationError('用户不存在');
return this.toDTO(account);
}
private toDTO(account: UserAccount): UserProfileDTO {
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
phoneNumber: account.phoneNumber?.masked() || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
province: account.province.value,
city: account.city.value,
address: account.addressDetail,
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
chainType: wa.chainType,
address: wa.address,
})),
kycStatus: account.kycStatus,
kycInfo: account.kycInfo
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
: null,
status: account.status,
registeredAt: account.registeredAt,
lastLoginAt: account.lastLoginAt,
};
}
}

View File

@ -0,0 +1,3 @@
export class GetMyProfileQuery {
constructor(public readonly userId: string) {}
}

View File

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
export interface TokenPayload {
userId: string;
accountSequence: number;
deviceId: string;
type: 'access' | 'refresh';
}
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async generateTokenPair(payload: {
userId: string;
accountSequence: number;
deviceId: string;
}): Promise<{ accessToken: string; refreshToken: string }> {
const accessToken = this.jwtService.sign(
{ ...payload, type: 'access' },
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
);
const refreshToken = this.jwtService.sign(
{ ...payload, type: 'refresh' },
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') },
);
// Save refresh token hash
const tokenHash = this.hashToken(refreshToken);
await this.prisma.deviceToken.create({
data: {
userId: BigInt(payload.userId),
deviceId: payload.deviceId,
refreshTokenHash: tokenHash,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
}
async verifyRefreshToken(token: string): Promise<{
userId: string;
accountSequence: number;
deviceId: string;
}> {
try {
const payload = this.jwtService.verify<TokenPayload>(token);
if (payload.type !== 'refresh') {
throw new ApplicationError('无效的RefreshToken');
}
const tokenHash = this.hashToken(token);
const storedToken = await this.prisma.deviceToken.findUnique({
where: { refreshTokenHash: tokenHash },
});
if (!storedToken || storedToken.revokedAt) {
throw new ApplicationError('RefreshToken已失效');
}
return {
userId: payload.userId,
accountSequence: payload.accountSequence,
deviceId: payload.deviceId,
};
} catch (error) {
if (error instanceof ApplicationError) throw error;
throw new ApplicationError('RefreshToken已过期或无效');
}
}
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
await this.prisma.deviceToken.updateMany({
where: { userId: BigInt(userId), deviceId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}

View File

@ -0,0 +1,424 @@
import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import {
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
} from '@/domain/services';
import {
UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode,
ChainType, Mnemonic, KYCInfo,
} from '@/domain/value-objects';
import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
} from '../commands';
@Injectable()
export class UserApplicationService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly sequenceGenerator: AccountSequenceGeneratorService,
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly redisService: RedisService,
private readonly smsService: SmsService,
private readonly eventPublisher: EventPublisherService,
) {}
async autoCreateAccount(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
const inviter = await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence;
}
const accountSequence = await this.sequenceGenerator.generateNext();
const account = UserAccount.createAutomatic({
accountSequence,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
inviterSequence,
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
city: CityCode.create(command.cityCode || 'DEFAULT'),
});
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
userId: account.userId,
deviceId: command.deviceId,
});
account.bindMultipleWalletAddresses(wallets);
await this.userRepository.save(account);
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: mnemonic.value,
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,
bsc: wallets.get(ChainType.BSC)!.address,
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async recoverByMnemonic(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
const mnemonic = Mnemonic.create(command.mnemonic);
const wallets = this.walletGenerator.recoverWalletSystem({
userId: account.userId,
mnemonic,
deviceId: command.newDeviceId,
});
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) {
throw new ApplicationError('助记词错误');
}
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin();
await this.userRepository.save(account);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async recoverByPhone(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
const accountSequence = AccountSequence.create(command.accountSequence);
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
const phoneNumber = PhoneNumber.create(command.phoneNumber);
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin();
await this.userRepository.save(account);
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.newDeviceId,
});
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async autoLogin(command: AutoLoginCommand): Promise<AutoLoginResult> {
const payload = await this.tokenService.verifyRefreshToken(command.refreshToken);
const account = await this.userRepository.findById(UserId.create(payload.userId));
if (!account || !account.isActive) throw new ApplicationError('账户不存在或已冻结');
if (!account.isDeviceAuthorized(command.deviceId)) {
throw new ApplicationError('设备未授权,请重新登录', 'DEVICE_UNAUTHORIZED');
}
account.addDevice(command.deviceId);
account.recordLogin();
await this.userRepository.save(account);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async sendSmsCode(command: SendSmsCodeCommand): Promise<void> {
const phoneNumber = PhoneNumber.create(command.phoneNumber);
const code = this.generateSmsCode();
const cacheKey = `sms:${command.type.toLowerCase()}:${phoneNumber.value}`;
await this.smsService.sendVerificationCode(phoneNumber.value, code);
await this.redisService.set(cacheKey, code, 300);
}
async register(command: RegisterCommand): Promise<RegisterResult> {
const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(`sms:register:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const phoneValidation = await this.validatorService.validatePhoneNumber(phoneNumber);
if (!phoneValidation.isValid) throw new ApplicationError(phoneValidation.errorMessage!);
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
const inviter = await this.userRepository.findByReferralCode(referralCode);
inviterSequence = inviter!.accountSequence;
}
const accountSequence = await this.sequenceGenerator.generateNext();
const account = UserAccount.create({
accountSequence,
phoneNumber,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
inviterSequence,
province: ProvinceCode.create(command.provinceCode),
city: CityCode.create(command.cityCode),
});
await this.userRepository.save(account);
await this.redisService.delete(`sms:register:${phoneNumber.value}`);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async login(command: LoginCommand): Promise<LoginResult> {
const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(`sms:login:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const account = await this.userRepository.findByPhoneNumber(phoneNumber);
if (!account) throw new ApplicationError('用户不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
account.addDevice(command.deviceId);
account.recordLogin();
await this.userRepository.save(account);
await this.redisService.delete(`sms:login:${phoneNumber.value}`);
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.value,
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
async bindPhoneNumber(command: BindPhoneNumberCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
const phoneNumber = PhoneNumber.create(command.phoneNumber);
const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`);
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
const validation = await this.validatorService.validatePhoneNumber(phoneNumber);
if (!validation.isValid) throw new ApplicationError(validation.errorMessage!);
account.bindPhoneNumber(phoneNumber);
await this.userRepository.save(account);
await this.redisService.delete(`sms:bind:${phoneNumber.value}`);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
async updateProfile(command: UpdateProfileCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
account.updateProfile({
nickname: command.nickname,
avatarUrl: command.avatarUrl,
address: command.address,
});
await this.userRepository.save(account);
}
async submitKYC(command: SubmitKYCCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
const kycInfo = KYCInfo.create({
realName: command.realName,
idCardNumber: command.idCardNumber,
idCardFrontUrl: command.idCardFrontUrl,
idCardBackUrl: command.idCardBackUrl,
});
account.submitKYC(kycInfo);
await this.userRepository.save(account);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
async reviewKYC(command: ReviewKYCCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
if (command.approved) {
account.approveKYC();
} else {
account.rejectKYC(command.reason || '审核未通过');
}
await this.userRepository.save(account);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
async getMyDevices(query: GetMyDevicesQuery): Promise<DeviceDTO[]> {
const account = await this.userRepository.findById(UserId.create(query.userId));
if (!account) throw new ApplicationError('用户不存在');
return account.getAllDevices().map((device) => ({
deviceId: device.deviceId,
deviceName: device.deviceName,
addedAt: device.addedAt,
lastActiveAt: device.lastActiveAt,
isCurrent: device.deviceId === query.currentDeviceId,
}));
}
async removeDevice(command: RemoveDeviceCommand): Promise<void> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
if (command.deviceIdToRemove === command.currentDeviceId) {
throw new ApplicationError('不能删除当前设备');
}
account.removeDevice(command.deviceIdToRemove);
await this.userRepository.save(account);
await this.tokenService.revokeDeviceTokens(account.userId.value, command.deviceIdToRemove);
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
}
async getMyProfile(query: GetMyProfileQuery): Promise<UserProfileDTO> {
const account = await this.userRepository.findById(UserId.create(query.userId));
if (!account) throw new ApplicationError('用户不存在');
return this.toUserProfileDTO(account);
}
async getUserByReferralCode(query: GetUserByReferralCodeQuery): Promise<UserBriefDTO | null> {
const account = await this.userRepository.findByReferralCode(ReferralCode.create(query.referralCode));
if (!account) return null;
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
};
}
private toUserProfileDTO(account: UserAccount): UserProfileDTO {
return {
userId: account.userId.value,
accountSequence: account.accountSequence.value,
phoneNumber: account.phoneNumber?.masked() || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
province: account.province.value,
city: account.city.value,
address: account.addressDetail,
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
chainType: wa.chainType,
address: wa.address,
})),
kycStatus: account.kycStatus,
kycInfo: account.kycInfo
? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() }
: null,
status: account.status,
registeredAt: account.registeredAt,
lastLoginAt: account.lastLoginAt,
};
}
private generateSmsCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
}

View File

@ -0,0 +1,4 @@
export const appConfig = () => ({
port: parseInt(process.env.APP_PORT || '3000', 10),
env: process.env.APP_ENV || 'development',
});

View File

@ -0,0 +1,3 @@
export const databaseConfig = () => ({
url: process.env.DATABASE_URL,
});

View File

@ -0,0 +1,36 @@
export const appConfig = () => ({
port: parseInt(process.env.APP_PORT || '3000', 10),
env: process.env.APP_ENV || 'development',
});
export const databaseConfig = () => ({
url: process.env.DATABASE_URL,
});
export const jwtConfig = () => ({
secret: process.env.JWT_SECRET || 'default-secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
});
export const redisConfig = () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
});
export const kafkaConfig = () => ({
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
});
export const smsConfig = () => ({
apiUrl: process.env.SMS_API_URL || '',
apiKey: process.env.SMS_API_KEY || '',
});
export const walletConfig = () => ({
encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt',
});

View File

@ -0,0 +1,5 @@
export const jwtConfig = () => ({
secret: process.env.JWT_SECRET || 'default-secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
});

View File

@ -0,0 +1,5 @@
export const kafkaConfig = () => ({
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
clientId: process.env.KAFKA_CLIENT_ID || 'identity-service',
groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group',
});

View File

@ -0,0 +1,6 @@
export const redisConfig = () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
});

View File

@ -0,0 +1,347 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode,
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
} from '@/domain/value-objects';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
UserLocationUpdatedEvent, UserAccountFrozenEvent, UserAccountDeactivatedEvent,
} from '@/domain/events';
export class UserAccount {
private readonly _userId: UserId;
private readonly _accountSequence: AccountSequence;
private _devices: Map<string, DeviceInfo>;
private _phoneNumber: PhoneNumber | null;
private _nickname: string;
private _avatarUrl: string | null;
private readonly _inviterSequence: AccountSequence | null;
private readonly _referralCode: ReferralCode;
private _province: ProvinceCode;
private _city: CityCode;
private _address: string | null;
private _walletAddresses: Map<ChainType, WalletAddress>;
private _kycInfo: KYCInfo | null;
private _kycStatus: KYCStatus;
private _status: AccountStatus;
private readonly _registeredAt: Date;
private _lastLoginAt: Date | null;
private _updatedAt: Date;
private _domainEvents: DomainEvent[] = [];
// Getters
get userId(): UserId { 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 province(): ProvinceCode { return this._province; }
get city(): CityCode { return this._city; }
get addressDetail(): 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; }
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
private constructor(
userId: UserId, accountSequence: AccountSequence, devices: Map<string, DeviceInfo>,
phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null,
inviterSequence: AccountSequence | null, referralCode: ReferralCode,
province: ProvinceCode, city: CityCode, address: string | null,
walletAddresses: Map<ChainType, WalletAddress>, kycInfo: KYCInfo | null,
kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date,
lastLoginAt: Date | null, updatedAt: Date,
) {
this._userId = userId;
this._accountSequence = accountSequence;
this._devices = devices;
this._phoneNumber = phoneNumber;
this._nickname = nickname;
this._avatarUrl = avatarUrl;
this._inviterSequence = inviterSequence;
this._referralCode = referralCode;
this._province = province;
this._city = city;
this._address = address;
this._walletAddresses = walletAddresses;
this._kycInfo = kycInfo;
this._kycStatus = kycStatus;
this._status = status;
this._registeredAt = registeredAt;
this._lastLoginAt = lastLoginAt;
this._updatedAt = updatedAt;
}
static createAutomatic(params: {
accountSequence: AccountSequence;
initialDeviceId: string;
deviceName?: string;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
}): UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
));
const account = new UserAccount(
UserId.generate(), params.accountSequence, devices, null,
`用户${params.accountSequence.value}`, null, params.inviterSequence,
ReferralCode.generate(), params.province, params.city, null,
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
new Date(), null, new Date(),
);
account.addDomainEvent(new UserAccountAutoCreatedEvent({
userId: account.userId.value,
accountSequence: params.accountSequence.value,
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
province: params.province.value,
city: params.city.value,
registeredAt: account._registeredAt,
}));
return account;
}
static create(params: {
accountSequence: AccountSequence;
phoneNumber: PhoneNumber;
initialDeviceId: string;
deviceName?: string;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
}): UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
));
const account = new UserAccount(
UserId.generate(), params.accountSequence, devices, params.phoneNumber,
`用户${params.accountSequence.value}`, null, params.inviterSequence,
ReferralCode.generate(), params.province, params.city, null,
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
new Date(), null, new Date(),
);
account.addDomainEvent(new UserAccountCreatedEvent({
userId: account.userId.value,
accountSequence: params.accountSequence.value,
phoneNumber: params.phoneNumber.value,
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
province: params.province.value,
city: params.city.value,
registeredAt: account._registeredAt,
}));
return account;
}
static reconstruct(params: {
userId: string; accountSequence: number; devices: DeviceInfo[];
phoneNumber: string | null; nickname: string; avatarUrl: string | null;
inviterSequence: number | null; referralCode: string;
province: string; city: string; address: string | null;
walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null;
kycStatus: KYCStatus; status: AccountStatus;
registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date;
}): UserAccount {
const deviceMap = new Map<string, DeviceInfo>();
params.devices.forEach(d => deviceMap.set(d.deviceId, d));
const walletMap = new Map<ChainType, WalletAddress>();
params.walletAddresses.forEach(w => walletMap.set(w.chainType, w));
return new UserAccount(
UserId.create(params.userId),
AccountSequence.create(params.accountSequence),
deviceMap,
params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null,
params.nickname,
params.avatarUrl,
params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null,
ReferralCode.create(params.referralCode),
ProvinceCode.create(params.province),
CityCode.create(params.city),
params.address,
walletMap,
params.kycInfo,
params.kycStatus,
params.status,
params.registeredAt,
params.lastLoginAt,
params.updatedAt,
);
}
addDevice(deviceId: string, deviceName?: string): void {
this.ensureActive();
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainError('最多允许5个设备同时登录');
}
if (this._devices.has(deviceId)) {
this._devices.get(deviceId)!.updateActivity();
} else {
this._devices.set(deviceId, new DeviceInfo(deviceId, deviceName || '未命名设备', new Date(), new Date()));
this.addDomainEvent(new DeviceAddedEvent({
userId: this.userId.value,
accountSequence: this.accountSequence.value,
deviceId,
deviceName: deviceName || '未命名设备',
}));
}
this._updatedAt = new Date();
}
removeDevice(deviceId: string): void {
this.ensureActive();
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
this._devices.delete(deviceId);
this._updatedAt = new Date();
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.value, deviceId }));
}
isDeviceAuthorized(deviceId: string): boolean {
return this._devices.has(deviceId);
}
getAllDevices(): DeviceInfo[] {
return Array.from(this._devices.values());
}
updateProfile(params: { nickname?: string; avatarUrl?: string; address?: string }): void {
this.ensureActive();
if (params.nickname) this._nickname = params.nickname;
if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl;
if (params.address !== undefined) this._address = params.address;
this._updatedAt = new Date();
}
updateLocation(province: ProvinceCode, city: CityCode): void {
this.ensureActive();
this._province = province;
this._city = city;
this._updatedAt = new Date();
this.addDomainEvent(new UserLocationUpdatedEvent({
userId: this.userId.value, province: province.value, city: city.value,
}));
}
bindPhoneNumber(phoneNumber: PhoneNumber): void {
this.ensureActive();
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
this._phoneNumber = phoneNumber;
this._updatedAt = new Date();
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.value, phoneNumber: phoneNumber.value }));
}
bindWalletAddress(chainType: ChainType, address: string): void {
this.ensureActive();
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
this._walletAddresses.set(chainType, walletAddress);
this._updatedAt = new Date();
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.value, chainType, address }));
}
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
this.ensureActive();
for (const [chainType, wallet] of wallets) {
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
this._walletAddresses.set(chainType, wallet);
}
this._updatedAt = new Date();
this.addDomainEvent(new MultipleWalletAddressesBoundEvent({
userId: this.userId.value,
addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })),
}));
}
submitKYC(kycInfo: KYCInfo): void {
this.ensureActive();
if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交');
this._kycInfo = kycInfo;
this._kycStatus = KYCStatus.PENDING;
this._updatedAt = new Date();
this.addDomainEvent(new KYCSubmittedEvent({
userId: this.userId.value, realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber,
}));
}
approveKYC(): void {
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC');
this._kycStatus = KYCStatus.VERIFIED;
this._updatedAt = new Date();
this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.value, verifiedAt: new Date() }));
}
rejectKYC(reason: string): void {
if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC');
this._kycStatus = KYCStatus.REJECTED;
this._updatedAt = new Date();
this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.value, reason }));
}
recordLogin(): void {
this.ensureActive();
this._lastLoginAt = new Date();
this._updatedAt = new Date();
}
freeze(reason: string): void {
if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结');
this._status = AccountStatus.FROZEN;
this._updatedAt = new Date();
this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.value, reason }));
}
unfreeze(): void {
if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结');
this._status = AccountStatus.ACTIVE;
this._updatedAt = new Date();
}
deactivate(): void {
if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销');
this._status = AccountStatus.DEACTIVATED;
this._updatedAt = new Date();
this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.value, deactivatedAt: new Date() }));
}
getWalletAddress(chainType: ChainType): WalletAddress | null {
return this._walletAddresses.get(chainType) || null;
}
getAllWalletAddresses(): WalletAddress[] {
return Array.from(this._walletAddresses.values());
}
private ensureActive(): void {
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
}
private addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): void {
this._domainEvents = [];
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { UserAccount } from './user-account.aggregate';
import { AccountSequence, PhoneNumber, ProvinceCode, CityCode } from '@/domain/value-objects';
@Injectable()
export class UserAccountFactory {
createAutomatic(params: {
accountSequence: AccountSequence;
initialDeviceId: string;
deviceName?: string;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
}): UserAccount {
return UserAccount.createAutomatic(params);
}
create(params: {
accountSequence: AccountSequence;
phoneNumber: PhoneNumber;
initialDeviceId: string;
deviceName?: string;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
}): UserAccount {
return UserAccount.create(params);
}
}

View File

@ -0,0 +1,79 @@
import { UserAccount } from './user-account.aggregate';
import { AccountSequence, ProvinceCode, CityCode } from '@/domain/value-objects';
import { DomainError } from '@/shared/exceptions/domain.exception';
describe('UserAccount', () => {
const createTestAccount = () => {
return UserAccount.createAutomatic({
accountSequence: AccountSequence.create(1),
initialDeviceId: 'device-001',
deviceName: 'Test Device',
inviterSequence: null,
province: ProvinceCode.create('110000'),
city: CityCode.create('110100'),
});
};
describe('createAutomatic', () => {
it('should create account with default values', () => {
const account = createTestAccount();
expect(account.accountSequence.value).toBe(1);
expect(account.nickname).toBe('用户1');
expect(account.isActive).toBe(true);
expect(account.phoneNumber).toBeNull();
});
it('should add initial device', () => {
const account = createTestAccount();
expect(account.isDeviceAuthorized('device-001')).toBe(true);
expect(account.getAllDevices()).toHaveLength(1);
});
});
describe('addDevice', () => {
it('should add new device', () => {
const account = createTestAccount();
account.addDevice('device-002', 'New Device');
expect(account.getAllDevices()).toHaveLength(2);
});
it('should throw error when exceeding device limit', () => {
const account = createTestAccount();
account.addDevice('device-002');
account.addDevice('device-003');
account.addDevice('device-004');
account.addDevice('device-005');
expect(() => account.addDevice('device-006')).toThrow(DomainError);
});
});
describe('removeDevice', () => {
it('should remove existing device', () => {
const account = createTestAccount();
account.addDevice('device-002');
account.removeDevice('device-002');
expect(account.getAllDevices()).toHaveLength(1);
});
it('should not remove last device', () => {
const account = createTestAccount();
expect(() => account.removeDevice('device-001')).toThrow(DomainError);
});
});
describe('freeze/unfreeze', () => {
it('should freeze active account', () => {
const account = createTestAccount();
account.freeze('Test reason');
expect(account.isActive).toBe(false);
});
it('should unfreeze frozen account', () => {
const account = createTestAccount();
account.freeze('Test reason');
account.unfreeze();
expect(account.isActive).toBe(true);
});
});
});

View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from './services';
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
@Module({
imports: [InfrastructureModule],
providers: [
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
UserAccountFactory,
],
exports: [
USER_ACCOUNT_REPOSITORY,
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
UserAccountFactory,
],
})
export class DomainModule {}

View File

@ -0,0 +1,167 @@
import { HDKey } from '@scure/bip32';
import { createHash } from 'crypto';
import { bech32 } from 'bech32';
import { Wallet } from 'ethers';
import { DomainError } from '@/shared/exceptions/domain.exception';
import {
AddressId,
UserId,
ChainType,
CHAIN_CONFIG,
AddressStatus,
Mnemonic,
MnemonicEncryption,
} from '@/domain/value-objects';
export class WalletAddress {
private readonly _addressId: AddressId;
private readonly _userId: UserId;
private readonly _chainType: ChainType;
private readonly _address: string;
private readonly _encryptedMnemonic: string;
private _status: AddressStatus;
private readonly _boundAt: Date;
get addressId(): AddressId { return this._addressId; }
get userId(): UserId { return this._userId; }
get chainType(): ChainType { return this._chainType; }
get address(): string { return this._address; }
get encryptedMnemonic(): string { return this._encryptedMnemonic; }
get status(): AddressStatus { return this._status; }
get boundAt(): Date { return this._boundAt; }
private constructor(
addressId: AddressId,
userId: UserId,
chainType: ChainType,
address: string,
encryptedMnemonic: string,
status: AddressStatus,
boundAt: Date,
) {
this._addressId = addressId;
this._userId = userId;
this._chainType = chainType;
this._address = address;
this._encryptedMnemonic = encryptedMnemonic;
this._status = status;
this._boundAt = boundAt;
}
static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress {
if (!this.validateAddress(params.chainType, params.address)) {
throw new DomainError(`${params.chainType}地址格式错误`);
}
return new WalletAddress(
AddressId.generate(),
params.userId,
params.chainType,
params.address,
'',
AddressStatus.ACTIVE,
new Date(),
);
}
static createFromMnemonic(params: {
userId: UserId;
chainType: ChainType;
mnemonic: Mnemonic;
encryptionKey: string;
}): WalletAddress {
const address = this.deriveAddress(params.chainType, params.mnemonic);
const encryptedMnemonic = MnemonicEncryption.encrypt(params.mnemonic.value, params.encryptionKey);
return new WalletAddress(
AddressId.generate(),
params.userId,
params.chainType,
address,
encryptedMnemonic,
AddressStatus.ACTIVE,
new Date(),
);
}
static reconstruct(params: {
addressId: string;
userId: string;
chainType: ChainType;
address: string;
encryptedMnemonic: string;
status: AddressStatus;
boundAt: Date;
}): WalletAddress {
return new WalletAddress(
AddressId.create(params.addressId),
UserId.create(params.userId),
params.chainType,
params.address,
params.encryptedMnemonic,
params.status,
params.boundAt,
);
}
disable(): void {
this._status = AddressStatus.DISABLED;
}
enable(): void {
this._status = AddressStatus.ACTIVE;
}
decryptMnemonic(encryptionKey: string): Mnemonic {
if (!this._encryptedMnemonic) {
throw new DomainError('该地址没有加密助记词');
}
const mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey);
return Mnemonic.create(mnemonicStr);
}
private static 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(Buffer.from(seed), config.derivationPath, config.prefix);
case ChainType.BSC:
return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath);
default:
throw new DomainError(`不支持的链类型: ${chainType}`);
}
}
private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.publicKey) throw new DomainError('无法派生公钥');
const hash = createHash('sha256').update(childKey.publicKey).digest();
const addressHash = createHash('ripemd160').update(hash).digest();
const words = bech32.toWords(addressHash);
return bech32.encode(prefix, words);
}
private static deriveEVMAddress(seed: Buffer, path: string): string {
const hdkey = HDKey.fromMasterSeed(seed);
const childKey = hdkey.derive(path);
if (!childKey.privateKey) throw new DomainError('无法派生私钥');
const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex'));
return wallet.address;
}
private static validateAddress(chainType: ChainType, address: string): boolean {
switch (chainType) {
case ChainType.KAVA:
case ChainType.DST:
return /^(kava|dst)1[a-z0-9]{38}$/.test(address);
case ChainType.BSC:
return /^0x[a-fA-F0-9]{40}$/.test(address);
default:
return false;
}
}
}

View File

@ -0,0 +1,18 @@
import { DomainEvent } from './index';
export class DeviceAddedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: number;
deviceId: string;
deviceName: string;
},
) {
super();
}
get eventType(): string {
return 'DeviceAdded';
}
}

View File

@ -0,0 +1,174 @@
export abstract class DomainEvent {
public readonly occurredAt: Date;
public readonly eventId: string;
constructor() {
this.occurredAt = new Date();
this.eventId = crypto.randomUUID();
}
abstract get eventType(): string;
}
export class UserAccountAutoCreatedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: number;
initialDeviceId: string;
inviterSequence: number | null;
province: string;
city: string;
registeredAt: Date;
},
) {
super();
}
get eventType(): string {
return 'UserAccountAutoCreated';
}
}
export class UserAccountCreatedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: number;
phoneNumber: string;
initialDeviceId: string;
inviterSequence: number | null;
province: string;
city: string;
registeredAt: Date;
},
) {
super();
}
get eventType(): string {
return 'UserAccountCreated';
}
}
export class DeviceAddedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: number;
deviceId: string;
deviceName: string;
},
) {
super();
}
get eventType(): string {
return 'DeviceAdded';
}
}
export class DeviceRemovedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; deviceId: string }) {
super();
}
get eventType(): string {
return 'DeviceRemoved';
}
}
export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
super();
}
get eventType(): string {
return 'PhoneNumberBound';
}
}
export class WalletAddressBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; chainType: string; address: string }) {
super();
}
get eventType(): string {
return 'WalletAddressBound';
}
}
export class MultipleWalletAddressesBoundEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
addresses: Array<{ chainType: string; address: string }>;
},
) {
super();
}
get eventType(): string {
return 'MultipleWalletAddressesBound';
}
}
export class KYCSubmittedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) {
super();
}
get eventType(): string {
return 'KYCSubmitted';
}
}
export class KYCVerifiedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; verifiedAt: Date }) {
super();
}
get eventType(): string {
return 'KYCVerified';
}
}
export class KYCRejectedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; reason: string }) {
super();
}
get eventType(): string {
return 'KYCRejected';
}
}
export class UserLocationUpdatedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; province: string; city: string }) {
super();
}
get eventType(): string {
return 'UserLocationUpdated';
}
}
export class UserAccountFrozenEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; reason: string }) {
super();
}
get eventType(): string {
return 'UserAccountFrozen';
}
}
export class UserAccountDeactivatedEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; deactivatedAt: Date }) {
super();
}
get eventType(): string {
return 'UserAccountDeactivated';
}
}

View File

@ -0,0 +1,11 @@
import { DomainEvent } from './index';
export class PhoneNumberBoundEvent extends DomainEvent {
constructor(public readonly payload: { userId: string; phoneNumber: string }) {
super();
}
get eventType(): string {
return 'PhoneNumberBound';
}
}

View File

@ -0,0 +1,22 @@
import { DomainEvent } from './index';
export class UserAccountCreatedEvent extends DomainEvent {
constructor(
public readonly payload: {
userId: string;
accountSequence: number;
phoneNumber: string;
initialDeviceId: string;
inviterSequence: number | null;
province: string;
city: string;
registeredAt: Date;
},
) {
super();
}
get eventType(): string {
return 'UserAccountCreated';
}
}

View File

@ -0,0 +1,30 @@
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus,
} from '@/domain/value-objects';
export interface Pagination {
page: number;
limit: number;
}
export interface UserAccountRepository {
save(account: UserAccount): Promise<void>;
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
findById(userId: UserId): Promise<UserAccount | null>;
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
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>;
getMaxAccountSequence(): Promise<AccountSequence | null>;
getNextAccountSequence(): Promise<AccountSequence>;
findUsers(
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string },
pagination?: Pagination,
): Promise<UserAccount[]>;
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
}
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');

View File

@ -0,0 +1,15 @@
import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { AccountSequence } from '@/domain/value-objects';
@Injectable()
export class AccountSequenceGeneratorService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly repository: UserAccountRepository,
) {}
async generateNext(): Promise<AccountSequence> {
return this.repository.getNextAccountSequence();
}
}

View File

@ -0,0 +1,121 @@
import { Injectable, Inject } from '@nestjs/common';
import { createHash } from 'crypto';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import {
AccountSequence, PhoneNumber, ReferralCode, ChainType, Mnemonic, UserId,
} from '@/domain/value-objects';
// ============ ValidationResult ============
export class ValidationResult {
private constructor(
public readonly isValid: boolean,
public readonly errorMessage: string | null,
) {}
static success(): ValidationResult {
return new ValidationResult(true, null);
}
static failure(message: string): ValidationResult {
return new ValidationResult(false, message);
}
}
// ============ AccountSequenceGeneratorService ============
@Injectable()
export class AccountSequenceGeneratorService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly repository: UserAccountRepository,
) {}
async generateNext(): Promise<AccountSequence> {
return this.repository.getNextAccountSequence();
}
}
// ============ UserValidatorService ============
@Injectable()
export class UserValidatorService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly repository: UserAccountRepository,
) {}
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
const existing = await this.repository.findByPhoneNumber(phoneNumber);
if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success();
}
async validateDeviceId(deviceId: string): Promise<ValidationResult> {
const existing = await this.repository.findByDeviceId(deviceId);
if (existing) return ValidationResult.failure('该设备已创建账户');
return ValidationResult.success();
}
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在');
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
return ValidationResult.success();
}
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(chainType, address);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success();
}
}
// ============ WalletGeneratorService ============
@Injectable()
export class WalletGeneratorService {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic: params.mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return wallets;
}
private deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
}

View File

@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
export class ValidationResult {
private constructor(
public readonly isValid: boolean,
public readonly errorMessage: string | null,
) {}
static success(): ValidationResult {
return new ValidationResult(true, null);
}
static failure(message: string): ValidationResult {
return new ValidationResult(false, message);
}
}
@Injectable()
export class UserValidatorService {
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly repository: UserAccountRepository,
) {}
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
const existing = await this.repository.findByPhoneNumber(phoneNumber);
if (existing) return ValidationResult.failure('该手机号已注册');
return ValidationResult.success();
}
async validateDeviceId(deviceId: string): Promise<ValidationResult> {
const existing = await this.repository.findByDeviceId(deviceId);
if (existing) return ValidationResult.failure('该设备已创建账户');
return ValidationResult.success();
}
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
const inviter = await this.repository.findByReferralCode(referralCode);
if (!inviter) return ValidationResult.failure('推荐码不存在');
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
return ValidationResult.success();
}
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
const existing = await this.repository.findByWalletAddress(chainType, address);
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
return ValidationResult.success();
}
}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
@Injectable()
export class WalletGeneratorService {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic: params.mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return wallets;
}
private deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
}

View File

@ -0,0 +1,19 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
export class AccountSequence {
constructor(public readonly value: number) {
if (value <= 0) throw new DomainError('账户序列号必须大于0');
}
static create(value: number): AccountSequence {
return new AccountSequence(value);
}
static next(current: AccountSequence): AccountSequence {
return new AccountSequence(current.value + 1);
}
equals(other: AccountSequence): boolean {
return this.value === other.value;
}
}

View File

@ -0,0 +1,20 @@
export class DeviceInfo {
private _lastActiveAt: Date;
constructor(
public readonly deviceId: string,
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
) {
this._lastActiveAt = lastActiveAt;
}
get lastActiveAt(): Date {
return this._lastActiveAt;
}
updateActivity(): void {
this._lastActiveAt = new Date();
}
}

View File

@ -0,0 +1,262 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
// ============ UserId ============
export class UserId {
constructor(public readonly value: string) {
if (!value) throw new DomainError('UserId不能为空');
}
static generate(): UserId {
return new UserId(crypto.randomUUID());
}
static create(value: string): UserId {
return new UserId(value);
}
equals(other: UserId): boolean {
return this.value === other.value;
}
}
// ============ AccountSequence ============
export class AccountSequence {
constructor(public readonly value: number) {
if (value <= 0) throw new DomainError('账户序列号必须大于0');
}
static create(value: number): AccountSequence {
return new AccountSequence(value);
}
static next(current: AccountSequence): AccountSequence {
return new AccountSequence(current.value + 1);
}
equals(other: AccountSequence): boolean {
return this.value === other.value;
}
}
// ============ PhoneNumber ============
export class PhoneNumber {
constructor(public readonly value: string) {
if (!/^1[3-9]\d{9}$/.test(value)) {
throw new DomainError('手机号格式错误');
}
}
static create(value: string): PhoneNumber {
return new PhoneNumber(value);
}
equals(other: PhoneNumber): boolean {
return this.value === other.value;
}
masked(): string {
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
}
// ============ ReferralCode ============
export class ReferralCode {
constructor(public readonly value: string) {
if (!/^[A-Z0-9]{6}$/.test(value)) {
throw new DomainError('推荐码格式错误');
}
}
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.toUpperCase());
}
equals(other: ReferralCode): boolean {
return this.value === other.value;
}
}
// ============ ProvinceCode & CityCode ============
export class ProvinceCode {
constructor(public readonly value: string) {}
static create(value: string): ProvinceCode {
return new ProvinceCode(value || 'DEFAULT');
}
}
export class CityCode {
constructor(public readonly value: string) {}
static create(value: string): CityCode {
return new CityCode(value || 'DEFAULT');
}
}
// ============ Mnemonic ============
export class Mnemonic {
constructor(public readonly value: string) {
if (!bip39.validateMnemonic(value, wordlist)) {
throw new DomainError('助记词格式错误');
}
}
static generate(): Mnemonic {
const mnemonic = bip39.generateMnemonic(wordlist, 128);
return new Mnemonic(mnemonic);
}
static create(value: string): Mnemonic {
return new Mnemonic(value);
}
toSeed(): Uint8Array {
return bip39.mnemonicToSeedSync(this.value);
}
getWords(): string[] {
return this.value.split(' ');
}
equals(other: Mnemonic): boolean {
return this.value === other.value;
}
}
// ============ DeviceInfo ============
export class DeviceInfo {
private _lastActiveAt: Date;
constructor(
public readonly deviceId: string,
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
) {
this._lastActiveAt = lastActiveAt;
}
get lastActiveAt(): Date {
return this._lastActiveAt;
}
updateActivity(): void {
this._lastActiveAt = new Date();
}
}
// ============ ChainType ============
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" },
};
// ============ KYCInfo ============
export class KYCInfo {
constructor(
public readonly realName: string,
public readonly idCardNumber: string,
public readonly idCardFrontUrl: string,
public readonly idCardBackUrl: string,
) {
if (!realName || realName.length < 2) {
throw new DomainError('真实姓名不合法');
}
if (!/^[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]$/.test(idCardNumber)) {
throw new DomainError('身份证号格式错误');
}
}
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');
}
}
// ============ Enums ============
export enum KYCStatus {
NOT_VERIFIED = 'NOT_VERIFIED',
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
REJECTED = 'REJECTED',
}
export enum AccountStatus {
ACTIVE = 'ACTIVE',
FROZEN = 'FROZEN',
DEACTIVATED = 'DEACTIVATED',
}
export enum AddressStatus {
ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED',
}
// ============ AddressId ============
export class AddressId {
constructor(public readonly value: string) {}
static generate(): AddressId {
return new AddressId(crypto.randomUUID());
}
static create(value: string): AddressId {
return new AddressId(value);
}
}
// ============ MnemonicEncryption ============
export class MnemonicEncryption {
static encrypt(mnemonic: string, key: string): string {
const derivedKey = this.deriveKey(key);
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 JSON.stringify({
encrypted,
authTag: authTag.toString('hex'),
iv: iv.toString('hex'),
});
}
static decrypt(encryptedData: string, key: string): string {
const { encrypted, authTag, iv } = JSON.parse(encryptedData);
const derivedKey = this.deriveKey(key);
const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private static deriveKey(password: string): Buffer {
return scryptSync(password, 'rwa-wallet-salt', 32);
}
}

View File

@ -0,0 +1,25 @@
import { DomainError } 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,
) {
if (!realName || realName.length < 2) {
throw new DomainError('真实姓名不合法');
}
if (!/^[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]$/.test(idCardNumber)) {
throw new DomainError('身份证号格式错误');
}
}
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');
}
}

View File

@ -0,0 +1,32 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
import * as bip39 from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
export class Mnemonic {
constructor(public readonly value: string) {
if (!bip39.validateMnemonic(value, wordlist)) {
throw new DomainError('助记词格式错误');
}
}
static generate(): Mnemonic {
const mnemonic = bip39.generateMnemonic(wordlist, 128);
return new Mnemonic(mnemonic);
}
static create(value: string): Mnemonic {
return new Mnemonic(value);
}
toSeed(): Uint8Array {
return bip39.mnemonicToSeedSync(this.value);
}
getWords(): string[] {
return this.value.split(' ');
}
equals(other: Mnemonic): boolean {
return this.value === other.value;
}
}

View File

@ -0,0 +1,21 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
export class PhoneNumber {
constructor(public readonly value: string) {
if (!/^1[3-9]\d{9}$/.test(value)) {
throw new DomainError('手机号格式错误');
}
}
static create(value: string): PhoneNumber {
return new PhoneNumber(value);
}
equals(other: PhoneNumber): boolean {
return this.value === other.value;
}
masked(): string {
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
}

View File

@ -0,0 +1,26 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
export class ReferralCode {
constructor(public readonly value: string) {
if (!/^[A-Z0-9]{6}$/.test(value)) {
throw new DomainError('推荐码格式错误');
}
}
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.toUpperCase());
}
equals(other: ReferralCode): boolean {
return this.value === other.value;
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WalletGeneratorServiceImpl } from './wallet-generator.service.impl';
@Module({
providers: [WalletGeneratorServiceImpl],
exports: [WalletGeneratorServiceImpl],
})
export class BlockchainModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
@Injectable()
export class WalletGeneratorServiceImpl {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic: params.mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return wallets;
}
private deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SmsService {
constructor(private readonly configService: ConfigService) {}
async sendSms(phoneNumber: string, content: string): Promise<boolean> {
const apiUrl = this.configService.get<string>('SMS_API_URL');
const apiKey = this.configService.get<string>('SMS_API_KEY');
// 实际项目中调用SMS API
console.log(`[SMS] Sending to ${phoneNumber}: ${content}`);
// 模拟发送成功
return true;
}
async sendVerificationCode(phoneNumber: string, code: string): Promise<boolean> {
const content = `您的验证码是${code},5分钟内有效。`;
return this.sendSms(phoneNumber, content);
}
}

View File

@ -0,0 +1,31 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './persistence/prisma/prisma.service';
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
import { RedisService } from './redis/redis.service';
import { EventPublisherService } from './kafka/event-publisher.service';
import { SmsService } from './external/sms/sms.service';
import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl';
@Global()
@Module({
providers: [
PrismaService,
UserAccountRepositoryImpl,
UserAccountMapper,
RedisService,
EventPublisherService,
SmsService,
WalletGeneratorServiceImpl,
],
exports: [
PrismaService,
UserAccountRepositoryImpl,
UserAccountMapper,
RedisService,
EventPublisherService,
SmsService,
WalletGeneratorServiceImpl,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,53 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
import { DomainEvent } from '@/domain/events';
@Injectable()
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private kafka: Kafka;
private producer: Producer;
constructor(private readonly configService: ConfigService) {
this.kafka = new Kafka({
clientId: this.configService.get<string>('KAFKA_CLIENT_ID', 'identity-service'),
brokers: (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(','),
logLevel: logLevel.WARN,
});
this.producer = this.kafka.producer();
}
async onModuleInit() {
await this.producer.connect();
}
async onModuleDestroy() {
await this.producer.disconnect();
}
async publish(event: DomainEvent): Promise<void> {
await this.producer.send({
topic: `identity.${event.eventType}`,
messages: [
{
key: event.eventId,
value: JSON.stringify({
eventId: event.eventId,
eventType: event.eventType,
occurredAt: event.occurredAt.toISOString(),
payload: (event as any).payload,
}),
},
],
});
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
@Injectable()
export class KafkaModule {}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EventPublisherService } from './event-publisher.service';
@Module({
providers: [EventPublisherService],
exports: [EventPublisherService],
})
export class KafkaModule {}

View File

@ -0,0 +1,44 @@
// Prisma Entity Types - 用于Mapper转换
export interface UserAccountEntity {
userId: bigint;
accountSequence: bigint;
phoneNumber: string | null;
nickname: string;
avatarUrl: string | null;
inviterSequence: bigint | null;
referralCode: string;
provinceCode: string;
cityCode: string;
address: string | null;
kycStatus: string;
realName: string | null;
idCardNumber: string | null;
idCardFrontUrl: string | null;
idCardBackUrl: string | null;
kycVerifiedAt: Date | null;
status: string;
registeredAt: Date;
lastLoginAt: Date | null;
updatedAt: Date;
devices?: UserDeviceEntity[];
walletAddresses?: WalletAddressEntity[];
}
export interface UserDeviceEntity {
id: bigint;
userId: bigint;
deviceId: string;
deviceName: string | null;
addedAt: Date;
lastActiveAt: Date;
}
export interface WalletAddressEntity {
addressId: bigint;
userId: bigint;
chainType: string;
address: string;
encryptedMnemonic: string | null;
status: string;
boundAt: Date;
}

View File

@ -0,0 +1,8 @@
export interface UserDeviceEntity {
id: bigint;
userId: bigint;
deviceId: string;
deviceName: string | null;
addedAt: Date;
lastActiveAt: Date;
}

View File

@ -0,0 +1,9 @@
export interface WalletAddressEntity {
addressId: bigint;
userId: bigint;
chainType: string;
address: string;
encryptedMnemonic: string | null;
status: string;
boundAt: Date;
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
import { UserAccountEntity } from '../entities/user-account.entity';
@Injectable()
export class UserAccountMapper {
toDomain(entity: UserAccountEntity): UserAccount {
const devices = (entity.devices || []).map(
(d) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt),
);
const wallets = (entity.walletAddresses || []).map((w) =>
WalletAddress.reconstruct({
addressId: w.addressId.toString(),
userId: w.userId.toString(),
chainType: w.chainType as ChainType,
address: w.address,
encryptedMnemonic: w.encryptedMnemonic || '',
status: w.status as AddressStatus,
boundAt: w.boundAt,
}),
);
const kycInfo =
entity.realName && entity.idCardNumber
? KYCInfo.create({
realName: entity.realName,
idCardNumber: entity.idCardNumber,
idCardFrontUrl: entity.idCardFrontUrl || '',
idCardBackUrl: entity.idCardBackUrl || '',
})
: null;
return UserAccount.reconstruct({
userId: entity.userId.toString(),
accountSequence: Number(entity.accountSequence),
devices,
phoneNumber: entity.phoneNumber,
nickname: entity.nickname,
avatarUrl: entity.avatarUrl,
inviterSequence: entity.inviterSequence ? Number(entity.inviterSequence) : null,
referralCode: entity.referralCode,
province: entity.provinceCode,
city: entity.cityCode,
address: entity.address,
walletAddresses: wallets,
kycInfo,
kycStatus: entity.kycStatus as KYCStatus,
status: entity.status as AccountStatus,
registeredAt: entity.registeredAt,
lastLoginAt: entity.lastLoginAt,
updatedAt: entity.updatedAt,
});
}
}

View File

@ -0,0 +1 @@
# Prisma migrations will be stored here

View File

@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,133 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model UserAccount {
userId 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)
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[]
@@index([phoneNumber], name: "idx_phone")
@@index([accountSequence], name: "idx_sequence")
@@index([referralCode], name: "idx_referral_code")
@@index([inviterSequence], name: "idx_inviter")
@@index([provinceCode, cityCode], name: "idx_province_city")
@@index([kycStatus], name: "idx_kyc_status")
@@index([status], name: "idx_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: [userId], onDelete: Cascade)
@@unique([userId, deviceId], name: "uk_user_device")
@@index([deviceId], name: "idx_device")
@@index([userId], name: "idx_user")
@@index([lastActiveAt], name: "idx_last_active")
@@map("user_devices")
}
model WalletAddress {
addressId 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: [userId], onDelete: Cascade)
@@unique([userId, chainType], name: "uk_user_chain")
@@unique([chainType, address], name: "uk_chain_address")
@@index([userId], name: "idx_wallet_user")
@@index([address], name: "idx_address")
@@map("wallet_addresses")
}
model AccountSequenceGenerator {
id Int @id @default(1)
currentSequence BigInt @default(0) @map("current_sequence")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("account_sequence_generator")
}
model UserEvent {
eventId 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.Timestamp(6)
version Int @default(1)
@@index([aggregateType, aggregateId], name: "idx_aggregate")
@@index([eventType], name: "idx_event_type")
@@index([userId], name: "idx_event_user")
@@index([occurredAt], name: "idx_occurred")
@@map("user_events")
}
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], name: "idx_user_device_token")
@@index([expiresAt], name: "idx_expires")
@@map("device_tokens")
}

View File

@ -0,0 +1,233 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { UserAccountRepository, Pagination } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
} from '@/domain/value-objects';
@Injectable()
export class UserAccountRepositoryImpl implements UserAccountRepository {
constructor(private readonly prisma: PrismaService) {}
async save(account: UserAccount): Promise<void> {
const devices = account.getAllDevices();
const wallets = account.getAllWalletAddresses();
await this.prisma.$transaction(async (tx) => {
await tx.userAccount.upsert({
where: { userId: BigInt(account.userId.value) },
create: {
userId: BigInt(account.userId.value),
accountSequence: BigInt(account.accountSequence.value),
phoneNumber: account.phoneNumber?.value || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
inviterSequence: account.inviterSequence ? BigInt(account.inviterSequence.value) : null,
referralCode: account.referralCode.value,
provinceCode: account.province.value,
cityCode: account.city.value,
address: account.addressDetail,
kycStatus: account.kycStatus,
realName: account.kycInfo?.realName || null,
idCardNumber: account.kycInfo?.idCardNumber || null,
idCardFrontUrl: account.kycInfo?.idCardFrontUrl || null,
idCardBackUrl: account.kycInfo?.idCardBackUrl || null,
status: account.status,
registeredAt: account.registeredAt,
lastLoginAt: account.lastLoginAt,
},
update: {
phoneNumber: account.phoneNumber?.value || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
provinceCode: account.province.value,
cityCode: account.city.value,
address: account.addressDetail,
kycStatus: account.kycStatus,
realName: account.kycInfo?.realName || null,
idCardNumber: account.kycInfo?.idCardNumber || null,
idCardFrontUrl: account.kycInfo?.idCardFrontUrl || null,
idCardBackUrl: account.kycInfo?.idCardBackUrl || null,
status: account.status,
lastLoginAt: account.lastLoginAt,
},
});
// Sync devices
await tx.userDevice.deleteMany({ where: { userId: BigInt(account.userId.value) } });
if (devices.length > 0) {
await tx.userDevice.createMany({
data: devices.map((d) => ({
userId: BigInt(account.userId.value),
deviceId: d.deviceId,
deviceName: d.deviceName,
addedAt: d.addedAt,
lastActiveAt: d.lastActiveAt,
})),
});
}
});
}
async saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void> {
await this.prisma.walletAddress.createMany({
data: wallets.map((w) => ({
userId: BigInt(userId.value),
chainType: w.chainType,
address: w.address,
encryptedMnemonic: w.encryptedMnemonic,
status: w.status,
boundAt: w.boundAt,
})),
skipDuplicates: true,
});
}
async findById(userId: UserId): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({
where: { userId: BigInt(userId.value) },
include: { devices: true, walletAddresses: true },
});
return data ? this.toDomain(data) : null;
}
async findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({
where: { accountSequence: BigInt(sequence.value) },
include: { devices: true, walletAddresses: true },
});
return data ? this.toDomain(data) : null;
}
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
const device = await this.prisma.userDevice.findFirst({ where: { deviceId } });
if (!device) return null;
return this.findById(UserId.create(device.userId.toString()));
}
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({
where: { phoneNumber: phoneNumber.value },
include: { devices: true, walletAddresses: true },
});
return data ? this.toDomain(data) : null;
}
async findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null> {
const data = await this.prisma.userAccount.findUnique({
where: { referralCode: referralCode.value },
include: { devices: true, walletAddresses: true },
});
return data ? this.toDomain(data) : null;
}
async findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null> {
const wallet = await this.prisma.walletAddress.findUnique({
where: { uk_chain_address: { chainType, address } },
});
if (!wallet) return null;
return this.findById(UserId.create(wallet.userId.toString()));
}
async getMaxAccountSequence(): Promise<AccountSequence | null> {
const result = await this.prisma.userAccount.aggregate({ _max: { accountSequence: true } });
return result._max.accountSequence ? AccountSequence.create(Number(result._max.accountSequence)) : null;
}
async getNextAccountSequence(): Promise<AccountSequence> {
const result = await this.prisma.$transaction(async (tx) => {
const updated = await tx.accountSequenceGenerator.update({
where: { id: 1 },
data: { currentSequence: { increment: 1 } },
});
return updated.currentSequence;
});
return AccountSequence.create(Number(result));
}
async findUsers(
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string },
pagination?: Pagination,
): Promise<UserAccount[]> {
const where: any = {};
if (filters?.status) where.status = filters.status;
if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
if (filters?.province) where.provinceCode = filters.province;
if (filters?.city) where.cityCode = filters.city;
if (filters?.keyword) {
where.OR = [
{ nickname: { contains: filters.keyword } },
{ phoneNumber: { contains: filters.keyword } },
];
}
const data = await this.prisma.userAccount.findMany({
where,
include: { devices: true, walletAddresses: true },
skip: pagination ? (pagination.page - 1) * pagination.limit : undefined,
take: pagination?.limit,
orderBy: { registeredAt: 'desc' },
});
return data.map((d) => this.toDomain(d));
}
async countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number> {
const where: any = {};
if (filters?.status) where.status = filters.status;
if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
return this.prisma.userAccount.count({ where });
}
private toDomain(data: any): UserAccount {
const devices = data.devices.map(
(d: any) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt),
);
const wallets = data.walletAddresses.map((w: any) =>
WalletAddress.reconstruct({
addressId: w.addressId.toString(),
userId: w.userId.toString(),
chainType: w.chainType as ChainType,
address: w.address,
encryptedMnemonic: w.encryptedMnemonic || '',
status: w.status as AddressStatus,
boundAt: w.boundAt,
}),
);
const kycInfo =
data.realName && data.idCardNumber
? KYCInfo.create({
realName: data.realName,
idCardNumber: data.idCardNumber,
idCardFrontUrl: data.idCardFrontUrl || '',
idCardBackUrl: data.idCardBackUrl || '',
})
: null;
return UserAccount.reconstruct({
userId: data.userId.toString(),
accountSequence: Number(data.accountSequence),
devices,
phoneNumber: data.phoneNumber,
nickname: data.nickname,
avatarUrl: data.avatarUrl,
inviterSequence: data.inviterSequence ? Number(data.inviterSequence) : null,
referralCode: data.referralCode,
province: data.provinceCode,
city: data.cityCode,
address: data.address,
walletAddresses: wallets,
kycInfo,
kycStatus: data.kycStatus as KYCStatus,
status: data.status as AccountStatus,
registeredAt: data.registeredAt,
lastLoginAt: data.lastLoginAt,
updatedAt: data.updatedAt,
});
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,50 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor(private readonly configService: ConfigService) {
this.client = new Redis({
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
port: this.configService.get<number>('REDIS_PORT', 6379),
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
db: this.configService.get<number>('REDIS_DB', 0),
});
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
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 incr(key: string): Promise<number> {
return this.client.incr(key);
}
async expire(key: string, seconds: number): Promise<void> {
await this.client.expire(key, seconds);
}
onModuleDestroy() {
this.client.disconnect();
}
}

View File

@ -0,0 +1,45 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global prefix
app.setGlobalPrefix('api/v1');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// CORS
app.enableCors({
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
});
// Swagger
const config = new DocumentBuilder()
.setTitle('Identity Service API')
.setDescription('RWA用户身份服务API')
.setVersion('2.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.APP_PORT || 3000;
await app.listen(port);
console.log(`Identity Service is running on port ${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { CurrentUserData } from '@/shared/guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData;
return data ? user?.[data] : user;
},
);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,11 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class ApplicationException extends HttpException {
constructor(
message: string,
public readonly code?: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ message, code, success: false }, status);
}
}

View File

@ -0,0 +1,40 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = 'DomainError';
}
}
export class ApplicationError extends Error {
constructor(
message: string,
public readonly code?: string,
) {
super(message);
this.name = 'ApplicationError';
}
}
export class BusinessException extends HttpException {
constructor(
message: string,
public readonly code?: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ message, code, success: false }, status);
}
}
export class UnauthorizedException extends HttpException {
constructor(message: string = '未授权访问') {
super({ message, success: false }, HttpStatus.UNAUTHORIZED);
}
}
export class NotFoundException extends HttpException {
constructor(message: string = '资源不存在') {
super({ message, success: false }, HttpStatus.NOT_FOUND);
}
}

View File

@ -0,0 +1,17 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { DomainError } from '@/shared/exceptions/domain.exception';
@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: DomainError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(HttpStatus.BAD_REQUEST).json({
success: false,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1,65 @@
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
Injectable, NestInterceptor, ExecutionContext, CallHandler,
} from '@nestjs/common';
import { Response } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
let code: string | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || message;
code = (exceptionResponse as any).code;
} else {
message = exceptionResponse as string;
}
} else if (exception instanceof DomainError) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
} else if (exception instanceof ApplicationError) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
code = exception.code;
} else if (exception instanceof Error) {
message = exception.message;
}
response.status(status).json({
success: false,
code,
message,
timestamp: new Date().toISOString(),
});
}
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
})),
);
}
}

View File

@ -0,0 +1,68 @@
import { Injectable, CanActivate, ExecutionContext, createParamDecorator, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { UnauthorizedException } from '@/shared/exceptions/domain.exception';
export interface JwtPayload {
userId: string;
accountSequence: number;
deviceId: string;
type: 'access' | 'refresh';
}
export interface CurrentUserData {
userId: string;
accountSequence: number;
deviceId: string;
}
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData;
return data ? user?.[data] : user;
},
);
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException('缺少认证令牌');
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
if (payload.type !== 'access') throw new UnauthorizedException('无效的令牌类型');
request.user = {
userId: payload.userId,
accountSequence: payload.accountSequence,
deviceId: payload.deviceId,
};
} catch {
throw new UnauthorizedException('令牌无效或已过期');
}
return true;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1,21 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
})),
);
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -0,0 +1 @@
# Prisma migrations will be stored here

View File

@ -0,0 +1,133 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model UserAccount {
userId 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)
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[]
@@index([phoneNumber], name: "idx_phone")
@@index([accountSequence], name: "idx_sequence")
@@index([referralCode], name: "idx_referral_code")
@@index([inviterSequence], name: "idx_inviter")
@@index([provinceCode, cityCode], name: "idx_province_city")
@@index([kycStatus], name: "idx_kyc_status")
@@index([status], name: "idx_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: [userId], onDelete: Cascade)
@@unique([userId, deviceId], name: "uk_user_device")
@@index([deviceId], name: "idx_device")
@@index([userId], name: "idx_user")
@@index([lastActiveAt], name: "idx_last_active")
@@map("user_devices")
}
model WalletAddress {
addressId 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: [userId], onDelete: Cascade)
@@unique([userId, chainType], name: "uk_user_chain")
@@unique([chainType, address], name: "uk_chain_address")
@@index([userId], name: "idx_wallet_user")
@@index([address], name: "idx_address")
@@map("wallet_addresses")
}
model AccountSequenceGenerator {
id Int @id @default(1)
currentSequence BigInt @default(0) @map("current_sequence")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("account_sequence_generator")
}
model UserEvent {
eventId 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.Timestamp(6)
version Int @default(1)
@@index([aggregateType, aggregateId], name: "idx_aggregate")
@@index([eventType], name: "idx_event_type")
@@index([userId], name: "idx_event_user")
@@index([occurredAt], name: "idx_occurred")
@@map("user_events")
}
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], name: "idx_user_device_token")
@@index([expiresAt], name: "idx_expires")
@@map("device_tokens")
}