This commit is contained in:
parent
c29c185a03
commit
44d2e2ad80
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="mysql://root:password@localhost:3306/rwa_identity"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
@ -1,38 +1,29 @@
|
||||||
# Application
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3000
|
|
||||||
API_PREFIX=api/v1
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/identity_db?schema=public"
|
DATABASE_URL="mysql://root:password@localhost:3306/rwa_identity"
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
REDIS_DB=0
|
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=your-super-secret-key-change-in-production
|
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
JWT_ACCESS_EXPIRATION=2h
|
JWT_ACCESS_EXPIRES_IN="2h"
|
||||||
JWT_REFRESH_EXPIRATION=30d
|
JWT_REFRESH_EXPIRES_IN="30d"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST="localhost"
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=""
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
# Kafka
|
# Kafka
|
||||||
KAFKA_BROKERS=localhost:9092
|
KAFKA_BROKERS="localhost:9092"
|
||||||
KAFKA_CLIENT_ID=identity-service
|
KAFKA_CLIENT_ID="identity-service"
|
||||||
KAFKA_GROUP_ID=identity-service-group
|
KAFKA_GROUP_ID="identity-service-group"
|
||||||
|
|
||||||
# SMS Provider (Aliyun)
|
# SMS Service
|
||||||
SMS_PROVIDER=aliyun
|
SMS_API_URL="https://sms-api.example.com"
|
||||||
SMS_ACCESS_KEY_ID=your-access-key-id
|
SMS_API_KEY="your-sms-api-key"
|
||||||
SMS_ACCESS_KEY_SECRET=your-access-key-secret
|
|
||||||
SMS_SIGN_NAME=RWA平台
|
|
||||||
SMS_TEMPLATE_CODE=SMS_123456789
|
|
||||||
|
|
||||||
# Blockchain RPC
|
# App
|
||||||
KAVA_RPC_URL=https://kava-rpc.example.com
|
APP_PORT=3000
|
||||||
DST_RPC_URL=https://dst-rpc.example.com
|
APP_ENV="development"
|
||||||
BSC_RPC_URL=https://bsc-dataseed.binance.org
|
|
||||||
|
|
||||||
# Wallet Encryption
|
# Blockchain Encryption
|
||||||
WALLET_ENCRYPTION_SALT=rwa-wallet-salt-change-in-production
|
WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="mysql://user:password@production-db:3306/rwa_identity"
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
@ -3,48 +3,28 @@ FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
RUN npm run prisma:generate
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Build
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built assets
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
# Create non-root user
|
ENV NODE_ENV=production
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nestjs -u 1001 -G nodejs
|
|
||||||
|
|
||||||
# Set ownership
|
|
||||||
RUN chown -R nestjs:nodejs /app
|
|
||||||
|
|
||||||
USER nestjs
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1
|
|
||||||
|
|
||||||
# Start
|
|
||||||
CMD ["npm", "run", "start:prod"]
|
CMD ["npm", "run", "start:prod"]
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,57 @@
|
||||||
# Identity Service
|
# Identity Service
|
||||||
|
|
||||||
RWA平台用户身份管理微服务 - 基于NestJS + Prisma + Clean Architecture
|
RWA用户身份上下文微服务 - 基于DDD架构的NestJS实现
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: NestJS 10
|
- **框架**: NestJS + TypeScript
|
||||||
- **ORM**: Prisma 5
|
- **ORM**: Prisma
|
||||||
- **数据库**: PostgreSQL 15
|
|
||||||
- **缓存**: Redis 7 (ioredis)
|
|
||||||
- **消息队列**: Kafka
|
- **消息队列**: Kafka
|
||||||
- **区块链**: ethers.js 6
|
- **缓存**: Redis (ioredis)
|
||||||
- **架构**: Clean Architecture / Hexagonal Architecture / DDD / CQRS
|
- **区块链**: ethers.js + @scure/bip32 + bech32
|
||||||
|
|
||||||
## 功能特性
|
## 项目结构
|
||||||
|
|
||||||
- ✅ 自动创建账户(首次打开APP)
|
|
||||||
- ✅ 多设备支持(最多5个设备同时登录)
|
|
||||||
- ✅ 助记词生成与恢复
|
|
||||||
- ✅ 手机号绑定与恢复
|
|
||||||
- ✅ 三链钱包地址派生(KAVA/DST/BSC)
|
|
||||||
- ✅ JWT Token认证
|
|
||||||
- ✅ Token自动刷新(账户永不过期)
|
|
||||||
- ✅ Kafka事件发布
|
|
||||||
- ✅ 死信队列与自动重试
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── api/ # 表现层
|
├── api/ # 表现层
|
||||||
│ ├── controllers/ # HTTP控制器
|
│ ├── controllers/ # 控制器
|
||||||
│ └── dto/ # 数据传输对象
|
│ └── dto/ # 请求/响应DTO
|
||||||
├── application/ # 应用层
|
├── application/ # 应用层
|
||||||
│ ├── commands/ # 命令处理器
|
│ ├── commands/ # 命令对象
|
||||||
│ ├── queries/ # 查询处理器
|
│ └── services/ # 应用服务
|
||||||
│ └── services/ # 应用服务
|
├── domain/ # 领域层
|
||||||
├── domain/ # 领域层
|
│ ├── aggregates/ # 聚合根
|
||||||
│ ├── aggregates/ # 聚合根
|
│ ├── entities/ # 实体
|
||||||
│ ├── entities/ # 实体
|
│ ├── events/ # 领域事件
|
||||||
│ ├── value-objects/ # 值对象
|
│ ├── repositories/ # 仓储接口
|
||||||
│ ├── events/ # 领域事件
|
│ ├── services/ # 领域服务
|
||||||
│ ├── repositories/ # 仓储接口
|
│ └── value-objects/ # 值对象
|
||||||
│ └── services/ # 领域服务
|
├── infrastructure/ # 基础设施层
|
||||||
├── infrastructure/ # 基础设施层
|
│ ├── persistence/ # 持久化
|
||||||
│ ├── persistence/ # 数据持久化
|
│ ├── redis/ # Redis服务
|
||||||
│ ├── redis/ # Redis缓存
|
│ ├── kafka/ # Kafka事件发布
|
||||||
│ ├── kafka/ # Kafka消息
|
│ └── external/ # 外部服务
|
||||||
│ └── external/ # 外部服务
|
├── shared/ # 共享层
|
||||||
└── shared/ # 共享层
|
│ ├── decorators/ # 装饰器
|
||||||
├── decorators/ # 装饰器
|
│ ├── guards/ # 守卫
|
||||||
├── guards/ # 守卫
|
│ ├── filters/ # 过滤器
|
||||||
├── filters/ # 过滤器
|
│ └── exceptions/ # 异常类
|
||||||
└── exceptions/ # 异常
|
└── config/ # 配置
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- ✅ 用户账户自动创建(首次打开APP)
|
||||||
|
- ✅ 多设备管理与授权(最多5个设备)
|
||||||
|
- ✅ 三链钱包地址生成(KAVA/DST/BSC)
|
||||||
|
- ✅ 助记词生成与加密存储
|
||||||
|
- ✅ 序列号+助记词恢复账户
|
||||||
|
- ✅ 序列号+手机号恢复账户
|
||||||
|
- ✅ KYC实名认证
|
||||||
|
- ✅ 推荐码生成与验证
|
||||||
|
- ✅ Token自动刷新机制
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
@ -66,16 +64,13 @@ npm install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑.env文件配置数据库连接等
|
# 编辑 .env 文件配置数据库等信息
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 数据库迁移
|
### 3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 生成Prisma客户端
|
|
||||||
npm run prisma:generate
|
npm run prisma:generate
|
||||||
|
|
||||||
# 运行迁移
|
|
||||||
npm run prisma:migrate
|
npm run prisma:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -90,92 +85,40 @@ npm run build
|
||||||
npm run start:prod
|
npm run start:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker部署
|
### 5. Docker部署
|
||||||
|
|
||||||
### 启动所有服务
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 仅启动依赖服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d postgres redis kafka zookeeper
|
|
||||||
```
|
|
||||||
|
|
||||||
## API文档
|
## API文档
|
||||||
|
|
||||||
启动服务后访问: http://localhost:3000/api/docs
|
启动服务后访问: http://localhost:3000/api/docs
|
||||||
|
|
||||||
## 主要API接口
|
## 主要API
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 | 认证 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|
|
||||||
| POST | /api/v1/user/auto-create | 自动创建账户 | 否 |
|
| POST | /user/auto-create | 自动创建账户 |
|
||||||
| POST | /api/v1/user/recover-by-mnemonic | 助记词恢复 | 否 |
|
| POST | /user/recover-by-mnemonic | 助记词恢复 |
|
||||||
| POST | /api/v1/user/recover-by-phone | 手机号恢复 | 否 |
|
| POST | /user/recover-by-phone | 手机号恢复 |
|
||||||
| POST | /api/v1/user/refresh-token | 刷新Token | 否 |
|
| POST | /user/auto-login | 自动登录 |
|
||||||
| POST | /api/v1/user/send-sms-code | 发送验证码 | 否 |
|
| GET | /user/my-profile | 我的资料 |
|
||||||
| POST | /api/v1/user/bind-phone | 绑定手机号 | 是 |
|
| GET | /user/my-devices | 我的设备 |
|
||||||
| GET | /api/v1/user/my-profile | 我的资料 | 是 |
|
| POST | /user/bind-phone | 绑定手机号 |
|
||||||
| GET | /api/v1/user/my-devices | 我的设备 | 是 |
|
| POST | /user/submit-kyc | 提交KYC |
|
||||||
| DELETE | /api/v1/user/remove-device | 移除设备 | 是 |
|
|
||||||
| POST | /api/v1/user/logout | 退出登录 | 是 |
|
|
||||||
|
|
||||||
## Kafka Topics
|
## 领域不变式
|
||||||
|
|
||||||
| Topic | 描述 |
|
1. 手机号在系统内唯一(可为空)
|
||||||
|-------|------|
|
2. 账户序列号全局唯一且递增
|
||||||
| identity.user-account.created | 用户账户创建 |
|
3. 每个账户最多5个设备同时登录
|
||||||
| identity.device.added | 设备添加 |
|
4. KYC认证通过后身份信息不可修改
|
||||||
| identity.device.removed | 设备移除 |
|
5. 每个区块链地址只能绑定一个账户
|
||||||
| identity.phone.bound | 手机号绑定 |
|
6. 推荐人序列号一旦设置终生不可修改
|
||||||
| identity.kyc.submitted | KYC提交 |
|
7. 助记词必须加密存储,只在创建时返回一次
|
||||||
| identity.kyc.approved | KYC通过 |
|
8. 三条链的钱包地址必须从同一个助记词派生
|
||||||
| identity.kyc.rejected | KYC拒绝 |
|
|
||||||
| identity.account.frozen | 账户冻结 |
|
|
||||||
| identity.wallet.bound | 钱包绑定 |
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 单元测试
|
|
||||||
npm run test
|
|
||||||
|
|
||||||
# E2E测试
|
|
||||||
npm run test:e2e
|
|
||||||
|
|
||||||
# 测试覆盖率
|
|
||||||
npm run test:cov
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 格式化代码
|
|
||||||
npm run format
|
|
||||||
|
|
||||||
# Lint检查
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
# 打开Prisma Studio
|
|
||||||
npm run prisma:studio
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环境变量
|
|
||||||
|
|
||||||
| 变量 | 描述 | 默认值 |
|
|
||||||
|------|------|--------|
|
|
||||||
| PORT | 服务端口 | 3000 |
|
|
||||||
| DATABASE_URL | 数据库连接 | - |
|
|
||||||
| REDIS_HOST | Redis主机 | localhost |
|
|
||||||
| REDIS_PORT | Redis端口 | 6379 |
|
|
||||||
| JWT_SECRET | JWT密钥 | - |
|
|
||||||
| JWT_ACCESS_EXPIRATION | AccessToken有效期 | 2h |
|
|
||||||
| JWT_REFRESH_EXPIRATION | RefreshToken有效期 | 30d |
|
|
||||||
| KAFKA_BROKERS | Kafka地址 | localhost:9092 |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
Proprietary
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- ============================================
|
||||||
|
-- Identity Context 数据库初始化
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS rwa_identity
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE rwa_identity;
|
||||||
|
|
||||||
|
-- 初始化账户序列号生成器
|
||||||
|
INSERT INTO account_sequence_generator (id, current_sequence) VALUES (1, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE id = id;
|
||||||
|
|
@ -2,124 +2,66 @@ version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
identity-service:
|
identity-service:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: identity-service
|
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- DATABASE_URL=mysql://root:password@mysql:3306/rwa_identity
|
||||||
- PORT=3000
|
- JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/identity_db?schema=public
|
- JWT_ACCESS_EXPIRES_IN=2h
|
||||||
|
- JWT_REFRESH_EXPIRES_IN=30d
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-key-change-in-production}
|
- KAFKA_BROKERS=kafka:9092
|
||||||
- JWT_ACCESS_EXPIRATION=2h
|
- APP_PORT=3000
|
||||||
- JWT_REFRESH_EXPIRATION=30d
|
- APP_ENV=production
|
||||||
- KAFKA_BROKERS=kafka:29092
|
|
||||||
- KAFKA_CLIENT_ID=identity-service
|
|
||||||
- KAFKA_GROUP_ID=identity-service-group
|
|
||||||
- WALLET_ENCRYPTION_SALT=${WALLET_ENCRYPTION_SALT:-rwa-wallet-salt}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
kafka:
|
kafka:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
postgres:
|
mysql:
|
||||||
image: postgres:15-alpine
|
image: mysql:8.0
|
||||||
container_name: identity-postgres
|
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=postgres
|
- MYSQL_ROOT_PASSWORD=password
|
||||||
- POSTGRES_PASSWORD=postgres
|
- MYSQL_DATABASE=rwa_identity
|
||||||
- POSTGRES_DB=identity_db
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- mysql_data:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 10
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: identity-redis
|
|
||||||
ports:
|
ports:
|
||||||
- '6379:6379'
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
|
||||||
test: ['CMD', 'redis-cli', 'ping']
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
|
|
||||||
zookeeper:
|
zookeeper:
|
||||||
image: confluentinc/cp-zookeeper:7.5.0
|
image: confluentinc/cp-zookeeper:7.5.0
|
||||||
container_name: identity-zookeeper
|
|
||||||
environment:
|
environment:
|
||||||
ZOOKEEPER_CLIENT_PORT: 2181
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
ZOOKEEPER_TICK_TIME: 2000
|
ZOOKEEPER_TICK_TIME: 2000
|
||||||
volumes:
|
|
||||||
- zookeeper-data:/var/lib/zookeeper/data
|
|
||||||
- zookeeper-logs:/var/lib/zookeeper/log
|
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
|
|
||||||
kafka:
|
kafka:
|
||||||
image: confluentinc/cp-kafka:7.5.0
|
image: confluentinc/cp-kafka:7.5.0
|
||||||
container_name: identity-kafka
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- zookeeper
|
- zookeeper
|
||||||
ports:
|
ports:
|
||||||
- '9092:9092'
|
- "9092:9092"
|
||||||
environment:
|
environment:
|
||||||
KAFKA_BROKER_ID: 1
|
KAFKA_BROKER_ID: 1
|
||||||
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
|
||||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
|
||||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
|
||||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
|
|
||||||
KAFKA_LOG_RETENTION_HOURS: 168
|
|
||||||
volumes:
|
|
||||||
- kafka-data:/var/lib/kafka/data
|
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
|
|
||||||
kafka-ui:
|
|
||||||
image: provectuslabs/kafka-ui:latest
|
|
||||||
container_name: identity-kafka-ui
|
|
||||||
ports:
|
|
||||||
- '8080:8080'
|
|
||||||
environment:
|
|
||||||
KAFKA_CLUSTERS_0_NAME: identity-cluster
|
|
||||||
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
|
|
||||||
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
|
|
||||||
depends_on:
|
|
||||||
- kafka
|
|
||||||
networks:
|
|
||||||
- rwa-network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
mysql_data:
|
||||||
redis-data:
|
redis_data:
|
||||||
zookeeper-data:
|
|
||||||
zookeeper-logs:
|
|
||||||
kafka-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
rwa-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "@nestjs/swagger",
|
||||||
|
"options": {
|
||||||
|
"classValidatorShim": true,
|
||||||
|
"introspectComments": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "identity-service",
|
"name": "identity-service",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Identity & User Context Microservice - RWA Platform",
|
"description": "RWA Identity & User Context Service",
|
||||||
"author": "RWA Team",
|
"author": "RWA Team",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "rimraf dist",
|
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
|
|
@ -22,87 +21,64 @@
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:migrate:prod": "prisma migrate deploy",
|
"prisma:migrate:prod": "prisma migrate deploy",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio"
|
||||||
"prisma:seed": "prisma db seed"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.3.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/cqrs": "^10.2.6",
|
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/microservices": "^10.3.0",
|
"@nestjs/microservices": "^10.0.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
|
||||||
"@nestjs/schedule": "^4.0.0",
|
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@scure/bip32": "^1.3.2",
|
"@scure/bip32": "^1.3.2",
|
||||||
|
"@scure/bip39": "^1.2.1",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"bip39": "^3.1.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"ethers": "^6.9.0",
|
"ethers": "^6.9.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"passport": "^0.7.0",
|
"reflect-metadata": "^0.1.13",
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"reflect-metadata": "^0.1.14",
|
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.1.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.3.1",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
"@types/uuid": "^9.0.7",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.18.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"eslint-plugin-prettier": "^5.1.2",
|
"jest": "^29.5.0",
|
||||||
"jest": "^29.7.0",
|
"prettier": "^3.0.0",
|
||||||
"prettier": "^3.1.1",
|
"prisma": "^5.7.0",
|
||||||
"prisma": "^5.8.0",
|
|
||||||
"rimraf": "^5.0.5",
|
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.0",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.1.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
"**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@app/(.*)$": "<rootDir>/$1",
|
"^@/(.*)$": "<rootDir>/$1"
|
||||||
"^@domain/(.*)$": "<rootDir>/domain/$1",
|
|
||||||
"^@application/(.*)$": "<rootDir>/application/$1",
|
|
||||||
"^@infrastructure/(.*)$": "<rootDir>/infrastructure/$1",
|
|
||||||
"^@api/(.*)$": "<rootDir>/api/$1",
|
|
||||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
|
||||||
"^@config/(.*)$": "<rootDir>/config/$1"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"seed": "ts-node prisma/seed.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,61 +3,51 @@ generator client {
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "mysql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户账户表
|
|
||||||
model UserAccount {
|
model UserAccount {
|
||||||
id BigInt @id @default(autoincrement()) @map("user_id")
|
userId BigInt @id @default(autoincrement()) @map("user_id")
|
||||||
accountSequence BigInt @unique @map("account_sequence")
|
accountSequence BigInt @unique @map("account_sequence")
|
||||||
|
|
||||||
// 基本信息
|
phoneNumber String? @unique @map("phone_number") @db.VarChar(20)
|
||||||
phoneNumber String? @unique @map("phone_number") @db.VarChar(20)
|
nickname String @db.VarChar(100)
|
||||||
nickname String @db.VarChar(100)
|
avatarUrl String? @map("avatar_url") @db.VarChar(500)
|
||||||
avatarUrl String? @map("avatar_url") @db.VarChar(500)
|
|
||||||
|
|
||||||
// 推荐信息
|
inviterSequence BigInt? @map("inviter_sequence")
|
||||||
inviterSequence BigInt? @map("inviter_sequence")
|
referralCode String @unique @map("referral_code") @db.VarChar(10)
|
||||||
referralCode String @unique @map("referral_code") @db.VarChar(10)
|
|
||||||
|
|
||||||
// 区域信息
|
provinceCode String @map("province_code") @db.VarChar(10)
|
||||||
provinceCode String @map("province_code") @db.VarChar(10)
|
cityCode String @map("city_code") @db.VarChar(10)
|
||||||
cityCode String @map("city_code") @db.VarChar(10)
|
address String? @db.VarChar(500)
|
||||||
address String? @db.VarChar(500)
|
|
||||||
|
|
||||||
// KYC信息
|
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
|
||||||
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
|
realName String? @map("real_name") @db.VarChar(100)
|
||||||
realName String? @map("real_name") @db.VarChar(100)
|
idCardNumber String? @map("id_card_number") @db.VarChar(20)
|
||||||
idCardNumber String? @map("id_card_number") @db.VarChar(20)
|
idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500)
|
||||||
idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500)
|
idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500)
|
||||||
idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500)
|
kycVerifiedAt DateTime? @map("kyc_verified_at")
|
||||||
kycVerifiedAt DateTime? @map("kyc_verified_at")
|
|
||||||
|
|
||||||
// 账户状态
|
status String @default("ACTIVE") @db.VarChar(20)
|
||||||
status String @default("ACTIVE") @db.VarChar(20)
|
|
||||||
|
|
||||||
// 时间戳
|
registeredAt DateTime @default(now()) @map("registered_at")
|
||||||
registeredAt DateTime @default(now()) @map("registered_at")
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
// 关联
|
devices UserDevice[]
|
||||||
devices UserDevice[]
|
walletAddresses WalletAddress[]
|
||||||
walletAddresses WalletAddress[]
|
|
||||||
events UserEvent[]
|
|
||||||
|
|
||||||
@@index([phoneNumber])
|
@@index([phoneNumber], name: "idx_phone")
|
||||||
@@index([accountSequence])
|
@@index([accountSequence], name: "idx_sequence")
|
||||||
@@index([referralCode])
|
@@index([referralCode], name: "idx_referral_code")
|
||||||
@@index([inviterSequence])
|
@@index([inviterSequence], name: "idx_inviter")
|
||||||
@@index([provinceCode, cityCode])
|
@@index([provinceCode, cityCode], name: "idx_province_city")
|
||||||
@@index([kycStatus])
|
@@index([kycStatus], name: "idx_kyc_status")
|
||||||
@@index([status])
|
@@index([status], name: "idx_status")
|
||||||
@@map("user_accounts")
|
@@map("user_accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户设备表
|
|
||||||
model UserDevice {
|
model UserDevice {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId BigInt @map("user_id")
|
userId BigInt @map("user_id")
|
||||||
|
|
@ -67,37 +57,65 @@ model UserDevice {
|
||||||
addedAt DateTime @default(now()) @map("added_at")
|
addedAt DateTime @default(now()) @map("added_at")
|
||||||
lastActiveAt DateTime @default(now()) @map("last_active_at")
|
lastActiveAt DateTime @default(now()) @map("last_active_at")
|
||||||
|
|
||||||
user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, deviceId])
|
@@unique([userId, deviceId], name: "uk_user_device")
|
||||||
@@index([deviceId])
|
@@index([deviceId], name: "idx_device")
|
||||||
@@index([userId])
|
@@index([userId], name: "idx_user")
|
||||||
@@index([lastActiveAt])
|
@@index([lastActiveAt], name: "idx_last_active")
|
||||||
@@map("user_devices")
|
@@map("user_devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 区块链钱包地址表
|
|
||||||
model WalletAddress {
|
model WalletAddress {
|
||||||
id BigInt @id @default(autoincrement()) @map("address_id")
|
addressId BigInt @id @default(autoincrement()) @map("address_id")
|
||||||
userId BigInt @map("user_id")
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
chainType String @map("chain_type") @db.VarChar(20)
|
chainType String @map("chain_type") @db.VarChar(20)
|
||||||
address String @db.VarChar(100)
|
address String @db.VarChar(100)
|
||||||
encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text
|
|
||||||
|
|
||||||
status String @default("ACTIVE") @db.VarChar(20)
|
encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text
|
||||||
boundAt DateTime @default(now()) @map("bound_at")
|
|
||||||
|
|
||||||
user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade)
|
status String @default("ACTIVE") @db.VarChar(20)
|
||||||
|
|
||||||
@@unique([userId, chainType])
|
boundAt DateTime @default(now()) @map("bound_at")
|
||||||
@@unique([chainType, address])
|
|
||||||
@@index([userId])
|
user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||||
@@index([address])
|
|
||||||
|
@@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")
|
@@map("wallet_addresses")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备Token表
|
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 {
|
model DeviceToken {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId BigInt @map("user_id")
|
userId BigInt @map("user_id")
|
||||||
|
|
@ -109,68 +127,7 @@ model DeviceToken {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
revokedAt DateTime? @map("revoked_at")
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
|
||||||
@@index([userId, deviceId])
|
@@index([userId, deviceId], name: "idx_user_device_token")
|
||||||
@@index([expiresAt])
|
@@index([expiresAt], name: "idx_expires")
|
||||||
@@map("device_tokens")
|
@@map("device_tokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户事件表
|
|
||||||
model UserEvent {
|
|
||||||
id BigInt @id @default(autoincrement()) @map("event_id")
|
|
||||||
eventType String @map("event_type") @db.VarChar(50)
|
|
||||||
|
|
||||||
aggregateId String @map("aggregate_id") @db.VarChar(100)
|
|
||||||
aggregateType String @map("aggregate_type") @db.VarChar(50)
|
|
||||||
|
|
||||||
eventData Json @map("event_data")
|
|
||||||
|
|
||||||
userId BigInt? @map("user_id")
|
|
||||||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(6)
|
|
||||||
version Int @default(1)
|
|
||||||
|
|
||||||
user UserAccount? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([aggregateType, aggregateId])
|
|
||||||
@@index([eventType])
|
|
||||||
@@index([userId])
|
|
||||||
@@index([occurredAt])
|
|
||||||
@@map("user_events")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 短信验证码表
|
|
||||||
model SmsCode {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
phoneNumber String @map("phone_number") @db.VarChar(20)
|
|
||||||
code String @db.VarChar(6)
|
|
||||||
type String @db.VarChar(20) // REGISTER, LOGIN, BIND, RECOVER
|
|
||||||
|
|
||||||
expiresAt DateTime @map("expires_at")
|
|
||||||
usedAt DateTime? @map("used_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
@@index([phoneNumber, type])
|
|
||||||
@@index([expiresAt])
|
|
||||||
@@map("sms_codes")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 死信队列表
|
|
||||||
model DeadLetterEvent {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
topic String @db.VarChar(100)
|
|
||||||
eventId String @map("event_id") @db.VarChar(50)
|
|
||||||
eventType String @map("event_type") @db.VarChar(50)
|
|
||||||
aggregateId String @map("aggregate_id") @db.VarChar(100)
|
|
||||||
aggregateType String @map("aggregate_type") @db.VarChar(50)
|
|
||||||
payload Json
|
|
||||||
errorMessage String @map("error_message") @db.Text
|
|
||||||
errorStack String? @map("error_stack") @db.Text
|
|
||||||
retryCount Int @default(0) @map("retry_count")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
processedAt DateTime? @map("processed_at")
|
|
||||||
|
|
||||||
@@index([topic])
|
|
||||||
@@index([eventType])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([processedAt])
|
|
||||||
@@map("dead_letter_events")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,314 +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 {
|
import {
|
||||||
Controller,
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||||
Post,
|
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||||
Get,
|
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
|
||||||
Delete,
|
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
|
||||||
Body,
|
} from '@/application/commands';
|
||||||
UseGuards,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
||||||
ApiOperation,
|
SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto,
|
||||||
ApiResponse,
|
BindWalletDto, SubmitKYCDto, RemoveDeviceDto,
|
||||||
ApiBearerAuth,
|
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
|
||||||
} from '@nestjs/swagger';
|
UserProfileResponseDto, DeviceResponseDto,
|
||||||
import {
|
} from '@/api/dto';
|
||||||
AutoCreateAccountDto,
|
|
||||||
RecoverByMnemonicDto,
|
|
||||||
RecoverByPhoneDto,
|
|
||||||
RefreshTokenDto,
|
|
||||||
SendSmsCodeDto,
|
|
||||||
BindPhoneDto,
|
|
||||||
RemoveDeviceDto,
|
|
||||||
} from '../dto/request';
|
|
||||||
import { AutoCreateAccountCommand } from '@application/commands/auto-create-account/auto-create-account.command';
|
|
||||||
import { RecoverByMnemonicCommand } from '@application/commands/recover-by-mnemonic/recover-by-mnemonic.command';
|
|
||||||
import { RecoverByPhoneCommand } from '@application/commands/recover-by-phone/recover-by-phone.command';
|
|
||||||
import { TokenService } from '@application/services/token.service';
|
|
||||||
import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service';
|
|
||||||
import {
|
|
||||||
IUserAccountRepository,
|
|
||||||
USER_ACCOUNT_REPOSITORY,
|
|
||||||
} from '@domain/repositories/user-account.repository.interface';
|
|
||||||
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
|
|
||||||
import { JwtAuthGuard } from '@shared/guards/jwt-auth.guard';
|
|
||||||
import { CurrentUser, CurrentDeviceId, Public } from '@shared/decorators';
|
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
|
||||||
|
|
||||||
@ApiTags('用户管理')
|
@ApiTags('User')
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class UserAccountController {
|
export class UserAccountController {
|
||||||
private readonly logger = new Logger(UserAccountController.name);
|
constructor(private readonly userService: UserApplicationService) {}
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly commandBus: CommandBus,
|
|
||||||
private readonly queryBus: QueryBus,
|
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
private readonly smsService: SmsService,
|
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
|
||||||
private readonly userRepository: IUserAccountRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('auto-create')
|
@Post('auto-create')
|
||||||
@Public()
|
@ApiOperation({ summary: '自动创建账户(首次打开APP)' })
|
||||||
@HttpCode(HttpStatus.OK)
|
@ApiResponse({ status: 200, type: AutoCreateAccountResponseDto })
|
||||||
@ApiOperation({ summary: '自动创建账户' })
|
|
||||||
@ApiResponse({ status: 200, description: '创建成功' })
|
|
||||||
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
async autoCreate(@Body() dto: AutoCreateAccountDto) {
|
||||||
const command = new AutoCreateAccountCommand(
|
return this.userService.autoCreateAccount(
|
||||||
dto.deviceId,
|
new AutoCreateAccountCommand(
|
||||||
dto.deviceName,
|
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
|
||||||
dto.inviterReferralCode,
|
dto.provinceCode, dto.cityCode,
|
||||||
dto.provinceCode,
|
),
|
||||||
dto.cityCode,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.commandBus.execute(command);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '账户创建成功',
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('recover-by-mnemonic')
|
@Post('recover-by-mnemonic')
|
||||||
@Public()
|
@ApiOperation({ summary: '用序列号+助记词恢复账户' })
|
||||||
@HttpCode(HttpStatus.OK)
|
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||||
@ApiOperation({ summary: '用助记词恢复账户' })
|
|
||||||
@ApiResponse({ status: 200, description: '恢复成功' })
|
|
||||||
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) {
|
||||||
const command = new RecoverByMnemonicCommand(
|
return this.userService.recoverByMnemonic(
|
||||||
dto.accountSequence,
|
new RecoverByMnemonicCommand(
|
||||||
dto.mnemonic,
|
dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName,
|
||||||
dto.newDeviceId,
|
),
|
||||||
dto.deviceName,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.commandBus.execute(command);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '账户恢复成功',
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('recover-by-phone')
|
@Post('recover-by-phone')
|
||||||
@Public()
|
@ApiOperation({ summary: '用序列号+手机号恢复账户' })
|
||||||
@HttpCode(HttpStatus.OK)
|
@ApiResponse({ status: 200, type: RecoverAccountResponseDto })
|
||||||
@ApiOperation({ summary: '用手机号恢复账户' })
|
|
||||||
@ApiResponse({ status: 200, description: '恢复成功' })
|
|
||||||
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
async recoverByPhone(@Body() dto: RecoverByPhoneDto) {
|
||||||
const command = new RecoverByPhoneCommand(
|
return this.userService.recoverByPhone(
|
||||||
dto.accountSequence,
|
new RecoverByPhoneCommand(
|
||||||
dto.phoneNumber,
|
dto.accountSequence, dto.phoneNumber, dto.smsCode,
|
||||||
dto.smsCode,
|
dto.newDeviceId, dto.deviceName,
|
||||||
dto.newDeviceId,
|
),
|
||||||
dto.deviceName,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.commandBus.execute(command);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '账户恢复成功',
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh-token')
|
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@Post('auto-login')
|
||||||
@ApiOperation({ summary: '刷新Token' })
|
@ApiOperation({ summary: '自动登录(Token刷新)' })
|
||||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
async refreshToken(@Body() dto: RefreshTokenDto) {
|
async autoLogin(@Body() dto: AutoLoginDto) {
|
||||||
const payload = await this.tokenService.verifyRefreshToken(dto.refreshToken);
|
return this.userService.autoLogin(
|
||||||
|
new AutoLoginCommand(dto.refreshToken, dto.deviceId),
|
||||||
// 验证设备
|
);
|
||||||
const account = await this.userRepository.findById(payload.userId);
|
|
||||||
if (!account) {
|
|
||||||
throw new ApplicationException('账户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!account.isDeviceAuthorized(dto.deviceId)) {
|
|
||||||
throw new ApplicationException('设备未授权');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新设备活跃时间
|
|
||||||
account.updateDeviceActivity(dto.deviceId);
|
|
||||||
await this.userRepository.save(account);
|
|
||||||
|
|
||||||
// 生成新Token
|
|
||||||
const tokens = await this.tokenService.refreshTokens(dto.refreshToken);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Token刷新成功',
|
|
||||||
data: tokens,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('send-sms-code')
|
@Post('send-sms-code')
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '发送短信验证码' })
|
@ApiOperation({ summary: '发送短信验证码' })
|
||||||
@ApiResponse({ status: 200, description: '发送成功' })
|
|
||||||
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
async sendSmsCode(@Body() dto: SendSmsCodeDto) {
|
||||||
const result = await this.smsService.sendVerificationCode(
|
await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type));
|
||||||
dto.phoneNumber,
|
return { message: '验证码已发送' };
|
||||||
dto.type as SmsType,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
@Public()
|
||||||
success: true,
|
@Post('register')
|
||||||
message: result.message,
|
@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')
|
@Post('bind-phone')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '绑定手机号' })
|
@ApiOperation({ summary: '绑定手机号' })
|
||||||
@ApiResponse({ status: 200, description: '绑定成功' })
|
async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) {
|
||||||
async bindPhone(@Body() dto: BindPhoneDto, @CurrentUser() user: any) {
|
await this.userService.bindPhoneNumber(
|
||||||
// 验证短信验证码
|
new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode),
|
||||||
await this.smsService.verifyCode(dto.phoneNumber, SmsType.BIND, dto.smsCode);
|
);
|
||||||
|
return { message: '绑定成功' };
|
||||||
// 获取账户
|
|
||||||
const account = await this.userRepository.findById(user.userId);
|
|
||||||
if (!account) {
|
|
||||||
throw new ApplicationException('账户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定手机号
|
|
||||||
const phoneNumber = PhoneNumber.create(dto.phoneNumber);
|
|
||||||
account.bindPhoneNumber(phoneNumber);
|
|
||||||
|
|
||||||
await this.userRepository.save(account);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '手机号绑定成功',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('my-profile')
|
@Get('my-profile')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '查询我的资料' })
|
@ApiOperation({ summary: '查询我的资料' })
|
||||||
@ApiResponse({ status: 200, description: '查询成功' })
|
@ApiResponse({ status: 200, type: UserProfileResponseDto })
|
||||||
async getMyProfile(@CurrentUser() user: any) {
|
async getMyProfile(@CurrentUser() user: CurrentUserData) {
|
||||||
const account = await this.userRepository.findById(user.userId);
|
return this.userService.getMyProfile(new GetMyProfileQuery(user.userId));
|
||||||
if (!account) {
|
}
|
||||||
throw new ApplicationException('账户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
@Put('update-profile')
|
||||||
success: true,
|
@ApiBearerAuth()
|
||||||
data: {
|
@ApiOperation({ summary: '更新用户资料' })
|
||||||
userId: account.userId,
|
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) {
|
||||||
accountSequence: account.accountSequence.value,
|
await this.userService.updateProfile(
|
||||||
phoneNumber: account.phoneNumber?.masked() || null,
|
new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl, dto.address),
|
||||||
nickname: account.nickname,
|
);
|
||||||
avatarUrl: account.avatarUrl,
|
return { message: '更新成功' };
|
||||||
referralCode: account.referralCode.value,
|
}
|
||||||
provinceCode: account.provinceCode,
|
|
||||||
cityCode: account.cityCode,
|
@Post('submit-kyc')
|
||||||
address: account.address,
|
@ApiBearerAuth()
|
||||||
walletAddresses: account.getAllWalletAddresses().map((w) => ({
|
@ApiOperation({ summary: '提交KYC认证' })
|
||||||
chainType: w.chainType,
|
async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) {
|
||||||
address: w.address,
|
await this.userService.submitKYC(
|
||||||
})),
|
new SubmitKYCCommand(
|
||||||
kycStatus: account.kycStatus,
|
user.userId, dto.realName, dto.idCardNumber,
|
||||||
status: account.status,
|
dto.idCardFrontUrl, dto.idCardBackUrl,
|
||||||
registeredAt: account.registeredAt,
|
),
|
||||||
lastLoginAt: account.lastLoginAt,
|
);
|
||||||
},
|
return { message: '提交成功' };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('my-devices')
|
@Get('my-devices')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '查询我的设备列表' })
|
@ApiOperation({ summary: '查看我的设备列表' })
|
||||||
@ApiResponse({ status: 200, description: '查询成功' })
|
@ApiResponse({ status: 200, type: [DeviceResponseDto] })
|
||||||
async getMyDevices(
|
async getMyDevices(@CurrentUser() user: CurrentUserData) {
|
||||||
@CurrentUser() user: any,
|
return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId));
|
||||||
@CurrentDeviceId() currentDeviceId: string,
|
|
||||||
) {
|
|
||||||
const account = await this.userRepository.findById(user.userId);
|
|
||||||
if (!account) {
|
|
||||||
throw new ApplicationException('账户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
const devices = account.getAllDevices().map((device) => ({
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
deviceName: device.deviceName,
|
|
||||||
addedAt: device.addedAt,
|
|
||||||
lastActiveAt: device.lastActiveAt,
|
|
||||||
isCurrent: device.deviceId === currentDeviceId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: devices,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('remove-device')
|
@Post('remove-device')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '移除设备' })
|
@ApiOperation({ summary: '移除设备' })
|
||||||
@ApiResponse({ status: 200, description: '移除成功' })
|
async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) {
|
||||||
async removeDevice(
|
await this.userService.removeDevice(
|
||||||
@Body() dto: RemoveDeviceDto,
|
new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId),
|
||||||
@CurrentUser() user: any,
|
);
|
||||||
@CurrentDeviceId() currentDeviceId: string,
|
return { message: '移除成功' };
|
||||||
) {
|
|
||||||
if (dto.deviceId === currentDeviceId) {
|
|
||||||
throw new ApplicationException('不能移除当前设备');
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await this.userRepository.findById(user.userId);
|
|
||||||
if (!account) {
|
|
||||||
throw new ApplicationException('账户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
account.removeDevice(dto.deviceId);
|
|
||||||
await this.userRepository.save(account);
|
|
||||||
|
|
||||||
// 吊销该设备的Token
|
|
||||||
await this.tokenService.revokeAllDeviceTokens(user.userId, dto.deviceId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '设备移除成功',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Public()
|
||||||
@UseGuards(JwtAuthGuard)
|
@Get('by-referral-code/:code')
|
||||||
@ApiBearerAuth()
|
@ApiOperation({ summary: '根据推荐码查询用户' })
|
||||||
@HttpCode(HttpStatus.OK)
|
async getByReferralCode(@Param('code') code: string) {
|
||||||
@ApiOperation({ summary: '退出登录' })
|
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
|
||||||
@ApiResponse({ status: 200, description: '退出成功' })
|
|
||||||
async logout(
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
@CurrentDeviceId() deviceId: string,
|
|
||||||
) {
|
|
||||||
await this.tokenService.revokeAllDeviceTokens(user.userId, deviceId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: '退出成功',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
// ============ Request DTOs ============
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 BindPhoneDto {
|
||||||
|
@ApiProperty({ example: '13800138000' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
|
phoneNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||||
|
smsCode: 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 SubmitKYCDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
realName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserProfileResponseDto {
|
||||||
|
@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()
|
||||||
|
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
kycStatus: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
kycInfo: { realName: string; idCardNumber: string } | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
registeredAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
deviceName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
addedAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
lastActiveAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
@ -1,178 +1 @@
|
||||||
import { IsString, IsOptional, Length, IsNumber, Min } from 'class-validator';
|
export * from '../index';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export class AutoCreateAccountDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '设备唯一标识',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
deviceId: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备名称',
|
|
||||||
example: 'iPhone 15 Pro',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
deviceName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '推荐码',
|
|
||||||
example: 'ABC123',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(6, 6)
|
|
||||||
@IsOptional()
|
|
||||||
inviterReferralCode?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '省份代码',
|
|
||||||
example: '110000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
provinceCode?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '城市代码',
|
|
||||||
example: '110100',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
cityCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecoverByMnemonicDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '账户序列号',
|
|
||||||
example: 10001,
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
accountSequence: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '12个单词的助记词',
|
|
||||||
example: 'abandon ability able about above absent absorb abstract absurd abuse access accident',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
mnemonic: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '新设备ID',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
newDeviceId: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备名称',
|
|
||||||
example: 'iPhone 15 Pro',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
deviceName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecoverByPhoneDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '账户序列号',
|
|
||||||
example: 10001,
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
accountSequence: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '手机号',
|
|
||||||
example: '13800138000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(11, 11)
|
|
||||||
phoneNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '短信验证码',
|
|
||||||
example: '123456',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(6, 6)
|
|
||||||
smsCode: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '新设备ID',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
newDeviceId: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备名称',
|
|
||||||
example: 'iPhone 15 Pro',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
deviceName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RefreshTokenDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Refresh Token',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '设备ID',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
deviceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SendSmsCodeDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '手机号',
|
|
||||||
example: '13800138000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(11, 11)
|
|
||||||
phoneNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '验证码类型',
|
|
||||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
|
||||||
example: 'BIND',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BindPhoneDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '手机号',
|
|
||||||
example: '13800138000',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(11, 11)
|
|
||||||
phoneNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '短信验证码',
|
|
||||||
example: '123456',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Length(6, 6)
|
|
||||||
smsCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RemoveDeviceDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '要移除的设备ID',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
deviceId: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1 @@
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
export * from '../index';
|
||||||
|
|
||||||
export class WalletAddressDto {
|
|
||||||
@ApiProperty()
|
|
||||||
chainType: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeviceDto {
|
|
||||||
@ApiProperty()
|
|
||||||
deviceId: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
deviceName: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
addedAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
lastActiveAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
isCurrent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserProfileDto {
|
|
||||||
@ApiProperty()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accountSequence: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
phoneNumber?: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
nickname: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
avatarUrl?: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
referralCode: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
provinceCode: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
cityCode: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
address?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [WalletAddressDto] })
|
|
||||||
walletAddresses: WalletAddressDto[];
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
kycStatus: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
status: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
registeredAt: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
lastLoginAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AutoCreateAccountResponseDto {
|
|
||||||
@ApiProperty()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accountSequence: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
referralCode: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '助记词(仅返回一次,请妥善保管)',
|
|
||||||
})
|
|
||||||
mnemonic: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
walletAddresses: {
|
|
||||||
kava: string;
|
|
||||||
dst: string;
|
|
||||||
bsc: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecoverAccountResponseDto {
|
|
||||||
@ApiProperty()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accountSequence: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
nickname: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
avatarUrl?: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
referralCode: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TokenResponseDto {
|
|
||||||
@ApiProperty()
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiResponseDto<T> {
|
|
||||||
@ApiProperty()
|
|
||||||
success: boolean;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
message: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
data?: T;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,92 +1,100 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module, Global } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
|
||||||
|
// 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
|
// Infrastructure
|
||||||
import { PrismaService } from './infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { UserAccountMapper } from './infrastructure/persistence/mappers/user-account.mapper';
|
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||||
import { UserAccountRepositoryImpl } from './infrastructure/persistence/repositories/user-account.repository.impl';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { KafkaModule } from './infrastructure/kafka/kafka.module';
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||||
import { WalletGeneratorService } from './infrastructure/external/blockchain/wallet-generator.service';
|
|
||||||
import { SmsService } from './infrastructure/external/sms/sms.service';
|
|
||||||
|
|
||||||
// Domain
|
|
||||||
import { USER_ACCOUNT_REPOSITORY } from './domain/repositories/user-account.repository.interface';
|
|
||||||
import { UserValidatorService } from './domain/services/user-validator.service';
|
|
||||||
|
|
||||||
// Application
|
|
||||||
import { TokenService } from './application/services/token.service';
|
|
||||||
import { AutoCreateAccountHandler } from './application/commands/auto-create-account/auto-create-account.handler';
|
|
||||||
import { RecoverByMnemonicHandler } from './application/commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
|
||||||
import { RecoverByPhoneHandler } from './application/commands/recover-by-phone/recover-by-phone.handler';
|
|
||||||
|
|
||||||
// API
|
|
||||||
import { UserAccountController } from './api/controllers/user-account.controller';
|
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
import { JwtStrategy } from './shared/strategies/jwt.strategy';
|
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
|
||||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
const CommandHandlers = [
|
// ============ Infrastructure Module ============
|
||||||
AutoCreateAccountHandler,
|
@Global()
|
||||||
RecoverByMnemonicHandler,
|
@Module({
|
||||||
RecoverByPhoneHandler,
|
providers: [PrismaService, RedisService, EventPublisherService, SmsService],
|
||||||
];
|
exports: [PrismaService, RedisService, EventPublisherService, SmsService],
|
||||||
|
})
|
||||||
|
export class InfrastructureModule {}
|
||||||
|
|
||||||
const QueryHandlers = [];
|
// ============ 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: ['.env', `.env.${process.env.NODE_ENV || 'development'}`],
|
load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig],
|
||||||
}),
|
}),
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
global: true,
|
||||||
useFactory: (configService: ConfigService) => ({
|
|
||||||
secret: configService.get('JWT_SECRET', 'default-secret'),
|
|
||||||
signOptions: {
|
|
||||||
expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '2h'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: { expiresIn: configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
InfrastructureModule,
|
||||||
CqrsModule,
|
DomainModule,
|
||||||
RedisModule,
|
ApplicationModule,
|
||||||
KafkaModule,
|
ApiModule,
|
||||||
],
|
],
|
||||||
controllers: [UserAccountController],
|
|
||||||
providers: [
|
providers: [
|
||||||
// Infrastructure
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
PrismaService,
|
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||||
UserAccountMapper,
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{
|
|
||||||
provide: USER_ACCOUNT_REPOSITORY,
|
|
||||||
useClass: UserAccountRepositoryImpl,
|
|
||||||
},
|
|
||||||
WalletGeneratorService,
|
|
||||||
SmsService,
|
|
||||||
|
|
||||||
// Domain Services
|
|
||||||
UserValidatorService,
|
|
||||||
|
|
||||||
// Application Services
|
|
||||||
TokenService,
|
|
||||||
|
|
||||||
// Auth
|
|
||||||
JwtStrategy,
|
|
||||||
JwtAuthGuard,
|
|
||||||
|
|
||||||
// CQRS Handlers
|
|
||||||
...CommandHandlers,
|
|
||||||
...QueryHandlers,
|
|
||||||
],
|
],
|
||||||
exports: [PrismaService],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export class AutoCreateAccountCommand {
|
export class AutoCreateAccountCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly deviceId: string,
|
public readonly deviceId: string,
|
||||||
public readonly deviceName: string | undefined,
|
public readonly deviceName?: string,
|
||||||
public readonly inviterReferralCode: string | undefined,
|
public readonly inviterReferralCode?: string,
|
||||||
public readonly provinceCode: string | undefined,
|
public readonly provinceCode?: string,
|
||||||
public readonly cityCode: string | undefined,
|
public readonly cityCode?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,147 +1,80 @@
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||||
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { UserValidatorService } from '@domain/services/user-validator.service';
|
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services';
|
||||||
import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service';
|
import { ReferralCode, AccountSequence, ProvinceCode, CityCode, ChainType } from '@/domain/value-objects';
|
||||||
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import {
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
IUserAccountRepository,
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
USER_ACCOUNT_REPOSITORY,
|
import { AutoCreateAccountResult } from '../index';
|
||||||
} from '@domain/repositories/user-account.repository.interface';
|
|
||||||
import { TokenService } from '@application/services/token.service';
|
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
|
||||||
|
|
||||||
export interface AutoCreateAccountResult {
|
|
||||||
userId: string;
|
|
||||||
accountSequence: number;
|
|
||||||
referralCode: string;
|
|
||||||
mnemonic: string;
|
|
||||||
walletAddresses: {
|
|
||||||
kava: string;
|
|
||||||
dst: string;
|
|
||||||
bsc: string;
|
|
||||||
};
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@CommandHandler(AutoCreateAccountCommand)
|
|
||||||
export class AutoCreateAccountHandler
|
|
||||||
implements ICommandHandler<AutoCreateAccountCommand, AutoCreateAccountResult>
|
|
||||||
{
|
|
||||||
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AutoCreateAccountHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly userRepository: IUserAccountRepository,
|
private readonly userRepository: UserAccountRepository,
|
||||||
|
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
||||||
private readonly validatorService: UserValidatorService,
|
private readonly validatorService: UserValidatorService,
|
||||||
private readonly walletGenerator: WalletGeneratorService,
|
private readonly walletGenerator: WalletGeneratorService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
||||||
command: AutoCreateAccountCommand,
|
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
|
||||||
): Promise<AutoCreateAccountResult> {
|
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
|
||||||
this.logger.log(`Creating account for device: ${command.deviceId}`);
|
|
||||||
|
|
||||||
// 1. 验证设备ID
|
|
||||||
const deviceValidation = await this.validatorService.validateDeviceId(
|
|
||||||
command.deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!deviceValidation.isValid) {
|
|
||||||
throw new ApplicationException(deviceValidation.errorMessage!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证推荐码(如果有)
|
|
||||||
let inviterSequence = null;
|
|
||||||
|
|
||||||
|
let inviterSequence: AccountSequence | null = null;
|
||||||
if (command.inviterReferralCode) {
|
if (command.inviterReferralCode) {
|
||||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||||
const referralValidation =
|
const referralValidation = await this.validatorService.validateReferralCode(referralCode);
|
||||||
await this.validatorService.validateReferralCode(referralCode);
|
if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!);
|
||||||
|
|
||||||
if (!referralValidation.isValid) {
|
|
||||||
throw new ApplicationException(referralValidation.errorMessage!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
||||||
inviterSequence = inviter!.accountSequence;
|
inviterSequence = inviter!.accountSequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 生成账户序列号
|
const accountSequence = await this.sequenceGenerator.generateNext();
|
||||||
const accountSequence = await this.userRepository.getNextAccountSequence();
|
|
||||||
|
|
||||||
// 4. 生成用户ID
|
|
||||||
const userId = String(accountSequence.value);
|
|
||||||
|
|
||||||
// 5. 创建用户账户
|
|
||||||
const account = UserAccount.createAutomatic({
|
const account = UserAccount.createAutomatic({
|
||||||
userId,
|
|
||||||
accountSequence,
|
accountSequence,
|
||||||
initialDeviceId: command.deviceId,
|
initialDeviceId: command.deviceId,
|
||||||
deviceName: command.deviceName,
|
deviceName: command.deviceName,
|
||||||
inviterSequence,
|
inviterSequence,
|
||||||
provinceCode: command.provinceCode || 'DEFAULT',
|
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
|
||||||
cityCode: command.cityCode || 'DEFAULT',
|
city: CityCode.create(command.cityCode || 'DEFAULT'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. 生成钱包
|
|
||||||
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
|
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
|
||||||
userId: account.userId,
|
userId: account.userId,
|
||||||
deviceId: command.deviceId,
|
deviceId: command.deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 绑定钱包
|
|
||||||
account.bindMultipleWalletAddresses(wallets);
|
account.bindMultipleWalletAddresses(wallets);
|
||||||
|
|
||||||
// 8. 保存
|
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
await this.userRepository.saveWallets(
|
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
|
||||||
account.userId,
|
|
||||||
Array.from(wallets.values()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. 生成Token
|
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
deviceId: command.deviceId,
|
deviceId: command.deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 10. 发布事件
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
await this.eventPublisher.publishUserAccountCreated({
|
account.clearDomainEvents();
|
||||||
userId: account.userId,
|
|
||||||
accountSequence: account.accountSequence.value,
|
|
||||||
initialDeviceId: command.deviceId,
|
|
||||||
inviterSequence: inviterSequence?.value || null,
|
|
||||||
provinceCode: account.provinceCode,
|
|
||||||
cityCode: account.cityCode,
|
|
||||||
referralCode: account.referralCode.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Account created: ${account.userId}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
referralCode: account.referralCode.value,
|
referralCode: account.referralCode.value,
|
||||||
mnemonic: mnemonic.value,
|
mnemonic: mnemonic.value,
|
||||||
walletAddresses: {
|
walletAddresses: {
|
||||||
kava: wallets.get('KAVA' as any)!.address,
|
kava: wallets.get(ChainType.KAVA)!.address,
|
||||||
dst: wallets.get('DST' as any)!.address,
|
dst: wallets.get(ChainType.DST)!.address,
|
||||||
bsc: wallets.get('BSC' as any)!.address,
|
bsc: wallets.get(ChainType.BSC)!.address,
|
||||||
},
|
},
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
|
||||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export class BindPhoneCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly phoneNumber: string,
|
||||||
|
public readonly smsCode: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,6 @@ export class RecoverByMnemonicCommand {
|
||||||
public readonly accountSequence: number,
|
public readonly accountSequence: number,
|
||||||
public readonly mnemonic: string,
|
public readonly mnemonic: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName: string | undefined,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,62 @@
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
|
||||||
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command';
|
||||||
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { Mnemonic } from '@domain/value-objects/mnemonic.vo';
|
import { WalletGeneratorService } from '@/domain/services';
|
||||||
import { ChainType } from '@domain/enums/chain-type.enum';
|
import { AccountSequence, ChainType, Mnemonic } from '@/domain/value-objects';
|
||||||
import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import {
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
IUserAccountRepository,
|
import { RecoverAccountResult } from '../index';
|
||||||
USER_ACCOUNT_REPOSITORY,
|
|
||||||
} from '@domain/repositories/user-account.repository.interface';
|
|
||||||
import { TokenService } from '@application/services/token.service';
|
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
|
||||||
|
|
||||||
export interface RecoverAccountResult {
|
|
||||||
userId: string;
|
|
||||||
accountSequence: number;
|
|
||||||
nickname: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
referralCode: string;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@CommandHandler(RecoverByMnemonicCommand)
|
|
||||||
export class RecoverByMnemonicHandler
|
|
||||||
implements ICommandHandler<RecoverByMnemonicCommand, RecoverAccountResult>
|
|
||||||
{
|
|
||||||
private readonly logger = new Logger(RecoverByMnemonicHandler.name);
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecoverByMnemonicHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly userRepository: IUserAccountRepository,
|
private readonly userRepository: UserAccountRepository,
|
||||||
private readonly walletGenerator: WalletGeneratorService,
|
private readonly walletGenerator: WalletGeneratorService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(command: RecoverByMnemonicCommand): Promise<RecoverAccountResult> {
|
||||||
command: RecoverByMnemonicCommand,
|
|
||||||
): Promise<RecoverAccountResult> {
|
|
||||||
this.logger.log(`Recovering account: ${command.accountSequence}`);
|
|
||||||
|
|
||||||
// 1. 查找账户
|
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account =
|
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
await this.userRepository.findByAccountSequence(accountSequence);
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
|
|
||||||
if (!account) {
|
const mnemonic = Mnemonic.create(command.mnemonic);
|
||||||
throw new ApplicationException('账户序列号不存在');
|
const wallets = this.walletGenerator.recoverWalletSystem({
|
||||||
}
|
userId: account.userId,
|
||||||
|
|
||||||
if (!account.isActive) {
|
|
||||||
throw new ApplicationException('账户已冻结或注销');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证助记词
|
|
||||||
let mnemonic: Mnemonic;
|
|
||||||
try {
|
|
||||||
mnemonic = Mnemonic.create(command.mnemonic);
|
|
||||||
} catch {
|
|
||||||
throw new ApplicationException('助记词格式错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 验证助记词是否匹配账户
|
|
||||||
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
|
|
||||||
if (!kavaWallet) {
|
|
||||||
throw new ApplicationException('账户钱包信息异常');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = this.walletGenerator.verifyMnemonic(
|
|
||||||
mnemonic,
|
mnemonic,
|
||||||
ChainType.KAVA,
|
deviceId: command.newDeviceId,
|
||||||
kavaWallet.address,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValid) {
|
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
|
||||||
throw new ApplicationException('助记词错误');
|
if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) {
|
||||||
|
throw new ApplicationError('助记词错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 添加新设备
|
|
||||||
account.addDevice(command.newDeviceId, command.deviceName);
|
account.addDevice(command.newDeviceId, command.deviceName);
|
||||||
account.recordLogin(command.newDeviceId);
|
account.recordLogin();
|
||||||
|
|
||||||
// 5. 保存更新
|
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
|
|
||||||
// 6. 生成Token
|
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
deviceId: command.newDeviceId,
|
deviceId: command.newDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 发布事件
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
await this.eventPublisher.publishDeviceAdded({
|
account.clearDomainEvents();
|
||||||
userId: account.userId,
|
|
||||||
accountSequence: account.accountSequence.value,
|
|
||||||
deviceId: command.newDeviceId,
|
|
||||||
deviceName: command.deviceName || '未命名设备',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Account recovered: ${account.userId}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
nickname: account.nickname,
|
nickname: account.nickname,
|
||||||
avatarUrl: account.avatarUrl,
|
avatarUrl: account.avatarUrl,
|
||||||
referralCode: account.referralCode.value,
|
referralCode: account.referralCode.value,
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
|
||||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ export class RecoverByPhoneCommand {
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: string,
|
public readonly smsCode: string,
|
||||||
public readonly newDeviceId: string,
|
public readonly newDeviceId: string,
|
||||||
public readonly deviceName: string | undefined,
|
public readonly deviceName?: string,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,58 @@
|
||||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
|
||||||
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
import { RecoverByPhoneCommand } from './recover-by-phone.command';
|
||||||
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
|
import { AccountSequence, PhoneNumber } from '@/domain/value-objects';
|
||||||
import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service';
|
import { TokenService } from '@/application/services/token.service';
|
||||||
import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import {
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
IUserAccountRepository,
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
USER_ACCOUNT_REPOSITORY,
|
import { RecoverAccountResult } from '../index';
|
||||||
} from '@domain/repositories/user-account.repository.interface';
|
|
||||||
import { TokenService } from '@application/services/token.service';
|
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
|
||||||
|
|
||||||
export interface RecoverByPhoneResult {
|
|
||||||
userId: string;
|
|
||||||
accountSequence: number;
|
|
||||||
nickname: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
referralCode: string;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@CommandHandler(RecoverByPhoneCommand)
|
|
||||||
export class RecoverByPhoneHandler
|
|
||||||
implements ICommandHandler<RecoverByPhoneCommand, RecoverByPhoneResult>
|
|
||||||
{
|
|
||||||
private readonly logger = new Logger(RecoverByPhoneHandler.name);
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecoverByPhoneHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly userRepository: IUserAccountRepository,
|
private readonly userRepository: UserAccountRepository,
|
||||||
private readonly smsService: SmsService,
|
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: RecoverByPhoneCommand): Promise<RecoverByPhoneResult> {
|
async execute(command: RecoverByPhoneCommand): Promise<RecoverAccountResult> {
|
||||||
this.logger.log(`Recovering account by phone: ${command.accountSequence}`);
|
|
||||||
|
|
||||||
// 1. 查找账户
|
|
||||||
const accountSequence = AccountSequence.create(command.accountSequence);
|
const accountSequence = AccountSequence.create(command.accountSequence);
|
||||||
const account =
|
const account = await this.userRepository.findByAccountSequence(accountSequence);
|
||||||
await this.userRepository.findByAccountSequence(accountSequence);
|
if (!account) throw new ApplicationError('账户序列号不存在');
|
||||||
|
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
|
||||||
if (!account) {
|
if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复');
|
||||||
throw new ApplicationException('账户序列号不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!account.isActive) {
|
|
||||||
throw new ApplicationException('账户已冻结或注销');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证手机号是否匹配
|
|
||||||
if (!account.phoneNumber) {
|
|
||||||
throw new ApplicationException('该账户未绑定手机号,请使用助记词恢复');
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
const phoneNumber = PhoneNumber.create(command.phoneNumber);
|
||||||
if (!account.phoneNumber.equals(phoneNumber)) {
|
if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配');
|
||||||
throw new ApplicationException('手机号不匹配');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 验证短信验证码
|
const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`);
|
||||||
await this.smsService.verifyCode(
|
if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期');
|
||||||
command.phoneNumber,
|
|
||||||
SmsType.RECOVER,
|
|
||||||
command.smsCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. 添加新设备
|
|
||||||
account.addDevice(command.newDeviceId, command.deviceName);
|
account.addDevice(command.newDeviceId, command.deviceName);
|
||||||
account.recordLogin(command.newDeviceId);
|
account.recordLogin();
|
||||||
|
|
||||||
// 5. 保存更新
|
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
|
await this.redisService.delete(`sms:recover:${phoneNumber.value}`);
|
||||||
|
|
||||||
// 6. 生成Token
|
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
deviceId: command.newDeviceId,
|
deviceId: command.newDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 发布事件
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
await this.eventPublisher.publishDeviceAdded({
|
account.clearDomainEvents();
|
||||||
userId: account.userId,
|
|
||||||
accountSequence: account.accountSequence.value,
|
|
||||||
deviceId: command.newDeviceId,
|
|
||||||
deviceName: command.deviceName || '未命名设备',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Account recovered by phone: ${account.userId}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: account.userId,
|
userId: account.userId.value,
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
nickname: account.nickname,
|
nickname: account.nickname,
|
||||||
avatarUrl: account.avatarUrl,
|
avatarUrl: account.avatarUrl,
|
||||||
referralCode: account.referralCode.value,
|
referralCode: account.referralCode.value,
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
|
||||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class GetMyDevicesQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly currentDeviceId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class GetMyProfileQuery {
|
||||||
|
constructor(public readonly userId: string) {}
|
||||||
|
}
|
||||||
|
|
@ -1,234 +1,93 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { RedisService } from '@infrastructure/redis/redis.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export interface TokenPair {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
accessTokenExpiresAt: Date;
|
|
||||||
refreshTokenExpiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: number;
|
accountSequence: number;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecodedToken extends TokenPayload {
|
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
private readonly logger = new Logger(TokenService.name);
|
|
||||||
private readonly accessTokenExpiration: string;
|
|
||||||
private readonly refreshTokenExpiration: string;
|
|
||||||
private readonly jwtSecret: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly redisService: RedisService,
|
private readonly prisma: PrismaService,
|
||||||
) {
|
) {}
|
||||||
this.jwtSecret = configService.get('JWT_SECRET', 'default-secret');
|
|
||||||
this.accessTokenExpiration = configService.get('JWT_ACCESS_EXPIRATION', '2h');
|
|
||||||
this.refreshTokenExpiration = configService.get('JWT_REFRESH_EXPIRATION', '30d');
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateTokenPair(payload: TokenPayload): Promise<TokenPair> {
|
async generateTokenPair(payload: {
|
||||||
const now = new Date();
|
userId: string;
|
||||||
|
accountSequence: number;
|
||||||
const accessToken = await this.jwtService.signAsync(
|
deviceId: string;
|
||||||
{
|
}): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
...payload,
|
const accessToken = this.jwtService.sign(
|
||||||
type: 'access',
|
{ ...payload, type: 'access' },
|
||||||
},
|
{ expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h') },
|
||||||
{
|
|
||||||
secret: this.jwtSecret,
|
|
||||||
expiresIn: this.accessTokenExpiration,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshToken = await this.jwtService.signAsync(
|
const refreshToken = this.jwtService.sign(
|
||||||
{
|
{ ...payload, type: 'refresh' },
|
||||||
...payload,
|
{ expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d') },
|
||||||
type: 'refresh',
|
);
|
||||||
|
|
||||||
|
// 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),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
secret: this.jwtSecret,
|
|
||||||
expiresIn: this.refreshTokenExpiration,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 计算过期时间
|
|
||||||
const accessTokenExpiresAt = this.calculateExpirationDate(
|
|
||||||
this.accessTokenExpiration,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
const refreshTokenExpiresAt = this.calculateExpirationDate(
|
|
||||||
this.refreshTokenExpiration,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.debug(`Generated token pair for user: ${payload.userId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
accessTokenExpiresAt,
|
|
||||||
refreshTokenExpiresAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyAccessToken(token: string): Promise<DecodedToken> {
|
|
||||||
try {
|
|
||||||
const payload = await this.jwtService.verifyAsync<DecodedToken>(token, {
|
|
||||||
secret: this.jwtSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (payload.type !== 'access') {
|
|
||||||
throw new ApplicationException('无效的AccessToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否在黑名单中
|
|
||||||
const tokenHash = this.hashToken(token);
|
|
||||||
const isBlacklisted = await this.redisService.isBlacklisted(tokenHash);
|
|
||||||
|
|
||||||
if (isBlacklisted) {
|
|
||||||
throw new ApplicationException('Token已被撤销');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApplicationException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new ApplicationException('AccessToken已过期或无效');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyRefreshToken(token: string): Promise<DecodedToken> {
|
|
||||||
try {
|
|
||||||
const payload = await this.jwtService.verifyAsync<DecodedToken>(token, {
|
|
||||||
secret: this.jwtSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (payload.type !== 'refresh') {
|
|
||||||
throw new ApplicationException('无效的RefreshToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否在黑名单中
|
|
||||||
const tokenHash = this.hashToken(token);
|
|
||||||
const isBlacklisted = await this.redisService.isBlacklisted(tokenHash);
|
|
||||||
|
|
||||||
if (isBlacklisted) {
|
|
||||||
throw new ApplicationException('Token已被撤销');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApplicationException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new ApplicationException('RefreshToken已过期或无效');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshTokens(refreshToken: string): Promise<TokenPair> {
|
|
||||||
const payload = await this.verifyRefreshToken(refreshToken);
|
|
||||||
|
|
||||||
// 将旧的refresh token加入黑名单
|
|
||||||
await this.revokeToken(refreshToken, payload.exp);
|
|
||||||
|
|
||||||
// 生成新的token对
|
|
||||||
return this.generateTokenPair({
|
|
||||||
userId: payload.userId,
|
|
||||||
accountSequence: payload.accountSequence,
|
|
||||||
deviceId: payload.deviceId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeToken(token: string, expiresAt?: number): Promise<void> {
|
async verifyRefreshToken(token: string): Promise<{
|
||||||
const tokenHash = this.hashToken(token);
|
userId: string;
|
||||||
|
accountSequence: number;
|
||||||
// 计算剩余有效期
|
deviceId: string;
|
||||||
let ttl: number;
|
}> {
|
||||||
if (expiresAt) {
|
try {
|
||||||
ttl = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
const payload = this.jwtService.verify<TokenPayload>(token);
|
||||||
} else {
|
if (payload.type !== 'refresh') {
|
||||||
try {
|
throw new ApplicationError('无效的RefreshToken');
|
||||||
const decoded = this.jwtService.decode(token) as DecodedToken;
|
|
||||||
ttl = Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
|
|
||||||
} catch {
|
|
||||||
ttl = 30 * 24 * 60 * 60; // 默认30天
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (ttl > 0) {
|
const tokenHash = this.hashToken(token);
|
||||||
await this.redisService.addToBlacklist(tokenHash, ttl);
|
const storedToken = await this.prisma.deviceToken.findUnique({
|
||||||
this.logger.debug(`Token revoked: ${tokenHash.substring(0, 16)}...`);
|
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 revokeAllDeviceTokens(
|
async revokeDeviceTokens(userId: string, deviceId: string): Promise<void> {
|
||||||
userId: string,
|
await this.prisma.deviceToken.updateMany({
|
||||||
deviceId: string,
|
where: { userId: BigInt(userId), deviceId, revokedAt: null },
|
||||||
): Promise<void> {
|
data: { revokedAt: new Date() },
|
||||||
// 标记设备的所有token为无效
|
});
|
||||||
const key = `revoked:${userId}:${deviceId}`;
|
|
||||||
await this.redisService.set(key, Date.now().toString(), 30 * 24 * 60 * 60);
|
|
||||||
|
|
||||||
this.logger.log(`All tokens revoked for user ${userId}, device ${deviceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
|
||||||
// 标记用户的所有token为无效
|
|
||||||
const key = `revoked:${userId}:*`;
|
|
||||||
await this.redisService.set(
|
|
||||||
`revoked:all:${userId}`,
|
|
||||||
Date.now().toString(),
|
|
||||||
30 * 24 * 60 * 60,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`All tokens revoked for user ${userId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
private hashToken(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateExpirationDate(expiration: string, from: Date): Date {
|
|
||||||
const result = new Date(from);
|
|
||||||
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Invalid expiration format: ${expiration}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
const unit = match[2];
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case 's':
|
|
||||||
result.setSeconds(result.getSeconds() + value);
|
|
||||||
break;
|
|
||||||
case 'm':
|
|
||||||
result.setMinutes(result.getMinutes() + value);
|
|
||||||
break;
|
|
||||||
case 'h':
|
|
||||||
result.setHours(result.getHours() + value);
|
|
||||||
break;
|
|
||||||
case 'd':
|
|
||||||
result.setDate(result.getDate() + value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const appConfig = () => ({
|
||||||
|
port: parseInt(process.env.APP_PORT || '3000', 10),
|
||||||
|
env: process.env.APP_ENV || 'development',
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const databaseConfig = () => ({
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
|
@ -1,271 +1,221 @@
|
||||||
import { AggregateRoot } from '@nestjs/cqrs';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import { AccountSequence } from '../../value-objects/account-sequence.vo';
|
import {
|
||||||
import { PhoneNumber } from '../../value-objects/phone-number.vo';
|
UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode,
|
||||||
import { ReferralCode } from '../../value-objects/referral-code.vo';
|
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
|
||||||
import { DeviceInfo } from '../../value-objects/device-info.vo';
|
} from '@/domain/value-objects';
|
||||||
import { KYCInfo } from '../../value-objects/kyc-info.vo';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { WalletAddress } from '../../entities/wallet-address.entity';
|
import {
|
||||||
import { ChainType } from '../../enums/chain-type.enum';
|
DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent,
|
||||||
import { KYCStatus } from '../../enums/kyc-status.enum';
|
DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent,
|
||||||
import { AccountStatus } from '../../enums/account-status.enum';
|
WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent,
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent,
|
||||||
import { UserAccountCreatedEvent } from '../../events/user-account-created.event';
|
UserLocationUpdatedEvent, UserAccountFrozenEvent, UserAccountDeactivatedEvent,
|
||||||
import { DeviceAddedEvent } from '../../events/device-added.event';
|
} from '@/domain/events';
|
||||||
import { PhoneNumberBoundEvent } from '../../events/phone-number-bound.event';
|
|
||||||
import { KYCSubmittedEvent } from '../../events/kyc-submitted.event';
|
|
||||||
|
|
||||||
export interface UserAccountProps {
|
export class UserAccount {
|
||||||
userId: string;
|
private readonly _userId: UserId;
|
||||||
accountSequence: AccountSequence;
|
private readonly _accountSequence: AccountSequence;
|
||||||
devices: Map<string, DeviceInfo>;
|
|
||||||
phoneNumber: PhoneNumber | null;
|
|
||||||
nickname: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
inviterSequence: AccountSequence | null;
|
|
||||||
referralCode: ReferralCode;
|
|
||||||
provinceCode: string;
|
|
||||||
cityCode: string;
|
|
||||||
address: string | null;
|
|
||||||
walletAddresses: Map<ChainType, WalletAddress>;
|
|
||||||
kycInfo: KYCInfo | null;
|
|
||||||
kycStatus: KYCStatus;
|
|
||||||
status: AccountStatus;
|
|
||||||
registeredAt: Date;
|
|
||||||
lastLoginAt: Date | null;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserAccount extends AggregateRoot {
|
|
||||||
private _userId: string;
|
|
||||||
private _accountSequence: AccountSequence;
|
|
||||||
private _devices: Map<string, DeviceInfo>;
|
private _devices: Map<string, DeviceInfo>;
|
||||||
private _phoneNumber: PhoneNumber | null;
|
private _phoneNumber: PhoneNumber | null;
|
||||||
private _nickname: string;
|
private _nickname: string;
|
||||||
private _avatarUrl: string | null;
|
private _avatarUrl: string | null;
|
||||||
private _inviterSequence: AccountSequence | null;
|
private readonly _inviterSequence: AccountSequence | null;
|
||||||
private _referralCode: ReferralCode;
|
private readonly _referralCode: ReferralCode;
|
||||||
private _provinceCode: string;
|
private _province: ProvinceCode;
|
||||||
private _cityCode: string;
|
private _city: CityCode;
|
||||||
private _address: string | null;
|
private _address: string | null;
|
||||||
private _walletAddresses: Map<ChainType, WalletAddress>;
|
private _walletAddresses: Map<ChainType, WalletAddress>;
|
||||||
private _kycInfo: KYCInfo | null;
|
private _kycInfo: KYCInfo | null;
|
||||||
private _kycStatus: KYCStatus;
|
private _kycStatus: KYCStatus;
|
||||||
private _status: AccountStatus;
|
private _status: AccountStatus;
|
||||||
private _registeredAt: Date;
|
private readonly _registeredAt: Date;
|
||||||
private _lastLoginAt: Date | null;
|
private _lastLoginAt: Date | null;
|
||||||
private _updatedAt: Date;
|
private _updatedAt: Date;
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
private constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get userId(): string {
|
get userId(): UserId { return this._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;
|
||||||
}
|
}
|
||||||
|
|
||||||
get accountSequence(): AccountSequence {
|
|
||||||
return this._accountSequence;
|
|
||||||
}
|
|
||||||
|
|
||||||
get phoneNumber(): PhoneNumber | null {
|
|
||||||
return this._phoneNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
get nickname(): string {
|
|
||||||
return this._nickname;
|
|
||||||
}
|
|
||||||
|
|
||||||
get avatarUrl(): string | null {
|
|
||||||
return this._avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
get inviterSequence(): AccountSequence | null {
|
|
||||||
return this._inviterSequence;
|
|
||||||
}
|
|
||||||
|
|
||||||
get referralCode(): ReferralCode {
|
|
||||||
return this._referralCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
get provinceCode(): string {
|
|
||||||
return this._provinceCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
get cityCode(): string {
|
|
||||||
return this._cityCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
get address(): string | null {
|
|
||||||
return this._address;
|
|
||||||
}
|
|
||||||
|
|
||||||
get kycInfo(): KYCInfo | null {
|
|
||||||
return this._kycInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
get kycStatus(): KYCStatus {
|
|
||||||
return this._kycStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status(): AccountStatus {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
get registeredAt(): Date {
|
|
||||||
return this._registeredAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get lastLoginAt(): Date | null {
|
|
||||||
return this._lastLoginAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get updatedAt(): Date {
|
|
||||||
return this._updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
|
||||||
return this._status === AccountStatus.ACTIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isKYCVerified(): boolean {
|
|
||||||
return this._kycStatus === KYCStatus.VERIFIED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static Factory Methods
|
|
||||||
static createAutomatic(params: {
|
static createAutomatic(params: {
|
||||||
userId: string;
|
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
initialDeviceId: string;
|
initialDeviceId: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
inviterSequence: AccountSequence | null;
|
inviterSequence: AccountSequence | null;
|
||||||
provinceCode: string;
|
province: ProvinceCode;
|
||||||
cityCode: string;
|
city: CityCode;
|
||||||
}): UserAccount {
|
}): UserAccount {
|
||||||
const account = new UserAccount();
|
|
||||||
|
|
||||||
const devices = new Map<string, DeviceInfo>();
|
const devices = new Map<string, DeviceInfo>();
|
||||||
const now = new Date();
|
devices.set(params.initialDeviceId, new DeviceInfo(
|
||||||
devices.set(
|
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
|
||||||
params.initialDeviceId,
|
));
|
||||||
new DeviceInfo(
|
|
||||||
params.initialDeviceId,
|
const account = new UserAccount(
|
||||||
params.deviceName || '未命名设备',
|
UserId.generate(), params.accountSequence, devices, null,
|
||||||
now,
|
`用户${params.accountSequence.value}`, null, params.inviterSequence,
|
||||||
now,
|
ReferralCode.generate(), params.province, params.city, null,
|
||||||
),
|
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
||||||
|
new Date(), null, new Date(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const referralCode = ReferralCode.generate();
|
account.addDomainEvent(new UserAccountAutoCreatedEvent({
|
||||||
|
userId: account.userId.value,
|
||||||
account._userId = params.userId;
|
accountSequence: params.accountSequence.value,
|
||||||
account._accountSequence = params.accountSequence;
|
initialDeviceId: params.initialDeviceId,
|
||||||
account._devices = devices;
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
account._phoneNumber = null;
|
province: params.province.value,
|
||||||
account._nickname = `用户${params.accountSequence.value}`;
|
city: params.city.value,
|
||||||
account._avatarUrl = null;
|
registeredAt: account._registeredAt,
|
||||||
account._inviterSequence = params.inviterSequence;
|
}));
|
||||||
account._referralCode = referralCode;
|
|
||||||
account._provinceCode = params.provinceCode;
|
|
||||||
account._cityCode = params.cityCode;
|
|
||||||
account._address = null;
|
|
||||||
account._walletAddresses = new Map();
|
|
||||||
account._kycInfo = null;
|
|
||||||
account._kycStatus = KYCStatus.NOT_VERIFIED;
|
|
||||||
account._status = AccountStatus.ACTIVE;
|
|
||||||
account._registeredAt = now;
|
|
||||||
account._lastLoginAt = null;
|
|
||||||
account._updatedAt = now;
|
|
||||||
|
|
||||||
account.apply(
|
|
||||||
new UserAccountCreatedEvent(
|
|
||||||
account._userId,
|
|
||||||
params.accountSequence.value,
|
|
||||||
params.initialDeviceId,
|
|
||||||
params.inviterSequence?.value || null,
|
|
||||||
params.provinceCode,
|
|
||||||
params.cityCode,
|
|
||||||
referralCode.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPersistence(props: UserAccountProps): UserAccount {
|
static create(params: {
|
||||||
const account = new UserAccount();
|
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(),
|
||||||
|
));
|
||||||
|
|
||||||
account._userId = props.userId;
|
const account = new UserAccount(
|
||||||
account._accountSequence = props.accountSequence;
|
UserId.generate(), params.accountSequence, devices, params.phoneNumber,
|
||||||
account._devices = props.devices;
|
`用户${params.accountSequence.value}`, null, params.inviterSequence,
|
||||||
account._phoneNumber = props.phoneNumber;
|
ReferralCode.generate(), params.province, params.city, null,
|
||||||
account._nickname = props.nickname;
|
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
|
||||||
account._avatarUrl = props.avatarUrl;
|
new Date(), null, new Date(),
|
||||||
account._inviterSequence = props.inviterSequence;
|
);
|
||||||
account._referralCode = props.referralCode;
|
|
||||||
account._provinceCode = props.provinceCode;
|
account.addDomainEvent(new UserAccountCreatedEvent({
|
||||||
account._cityCode = props.cityCode;
|
userId: account.userId.value,
|
||||||
account._address = props.address;
|
accountSequence: params.accountSequence.value,
|
||||||
account._walletAddresses = props.walletAddresses;
|
phoneNumber: params.phoneNumber.value,
|
||||||
account._kycInfo = props.kycInfo;
|
initialDeviceId: params.initialDeviceId,
|
||||||
account._kycStatus = props.kycStatus;
|
inviterSequence: params.inviterSequence?.value || null,
|
||||||
account._status = props.status;
|
province: params.province.value,
|
||||||
account._registeredAt = props.registeredAt;
|
city: params.city.value,
|
||||||
account._lastLoginAt = props.lastLoginAt;
|
registeredAt: account._registeredAt,
|
||||||
account._updatedAt = props.updatedAt;
|
}));
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain Methods
|
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 {
|
addDevice(deviceId: string, deviceName?: string): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
|
|
||||||
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
|
||||||
throw new DomainException('最多允许5个设备同时登录');
|
throw new DomainError('最多允许5个设备同时登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (this._devices.has(deviceId)) {
|
if (this._devices.has(deviceId)) {
|
||||||
this._devices.get(deviceId)!.updateActivity();
|
this._devices.get(deviceId)!.updateActivity();
|
||||||
} else {
|
} else {
|
||||||
this._devices.set(
|
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,
|
deviceId,
|
||||||
new DeviceInfo(
|
deviceName: deviceName || '未命名设备',
|
||||||
deviceId,
|
}));
|
||||||
deviceName || '未命名设备',
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.apply(
|
|
||||||
new DeviceAddedEvent(
|
|
||||||
this._userId,
|
|
||||||
this._accountSequence.value,
|
|
||||||
deviceId,
|
|
||||||
deviceName || '未命名设备',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
this._updatedAt = new Date();
|
||||||
this._updatedAt = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeDevice(deviceId: string): void {
|
removeDevice(deviceId: string): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
|
if (!this._devices.has(deviceId)) throw new DomainError('设备不存在');
|
||||||
if (!this._devices.has(deviceId)) {
|
if (this._devices.size <= 1) throw new DomainError('至少保留一个设备');
|
||||||
throw new DomainException('设备不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._devices.size <= 1) {
|
|
||||||
throw new DomainException('至少保留一个设备');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._devices.delete(deviceId);
|
this._devices.delete(deviceId);
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.value, deviceId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
isDeviceAuthorized(deviceId: string): boolean {
|
isDeviceAuthorized(deviceId: string): boolean {
|
||||||
|
|
@ -276,52 +226,103 @@ export class UserAccount extends AggregateRoot {
|
||||||
return Array.from(this._devices.values());
|
return Array.from(this._devices.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
getDeviceCount(): number {
|
updateProfile(params: { nickname?: string; avatarUrl?: string; address?: string }): void {
|
||||||
return this._devices.size;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDeviceActivity(deviceId: string): void {
|
updateLocation(province: ProvinceCode, city: CityCode): void {
|
||||||
if (this._devices.has(deviceId)) {
|
this.ensureActive();
|
||||||
this._devices.get(deviceId)!.updateActivity();
|
this._province = province;
|
||||||
this._updatedAt = new Date();
|
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 {
|
bindPhoneNumber(phoneNumber: PhoneNumber): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
|
if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定');
|
||||||
if (this._phoneNumber) {
|
|
||||||
throw new DomainException('已绑定手机号,不可重复绑定');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._phoneNumber = phoneNumber;
|
this._phoneNumber = phoneNumber;
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.value, phoneNumber: phoneNumber.value }));
|
||||||
this.apply(new PhoneNumberBoundEvent(this._userId, phoneNumber.value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bindWalletAddress(wallet: WalletAddress): void {
|
bindWalletAddress(chainType: ChainType, address: string): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
|
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
||||||
if (this._walletAddresses.has(wallet.chainType)) {
|
const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address });
|
||||||
throw new DomainException(`已绑定${wallet.chainType}地址`);
|
this._walletAddresses.set(chainType, walletAddress);
|
||||||
}
|
|
||||||
|
|
||||||
this._walletAddresses.set(wallet.chainType, wallet);
|
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
|
this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.value, chainType, address }));
|
||||||
}
|
}
|
||||||
|
|
||||||
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
bindMultipleWalletAddresses(wallets: Map<ChainType, WalletAddress>): void {
|
||||||
this.ensureActive();
|
this.ensureActive();
|
||||||
|
|
||||||
for (const [chainType, wallet] of wallets) {
|
for (const [chainType, wallet] of wallets) {
|
||||||
if (this._walletAddresses.has(chainType)) {
|
if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`);
|
||||||
throw new DomainException(`已绑定${chainType}地址`);
|
|
||||||
}
|
|
||||||
this._walletAddresses.set(chainType, wallet);
|
this._walletAddresses.set(chainType, wallet);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._updatedAt = new Date();
|
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 {
|
getWalletAddress(chainType: ChainType): WalletAddress | null {
|
||||||
|
|
@ -332,139 +333,15 @@ export class UserAccount extends AggregateRoot {
|
||||||
return Array.from(this._walletAddresses.values());
|
return Array.from(this._walletAddresses.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
submitKYC(kycInfo: KYCInfo): void {
|
|
||||||
this.ensureActive();
|
|
||||||
|
|
||||||
if (this._kycStatus === KYCStatus.VERIFIED) {
|
|
||||||
throw new DomainException('已通过KYC认证,不可重复提交');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._kycInfo = kycInfo;
|
|
||||||
this._kycStatus = KYCStatus.PENDING;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
|
|
||||||
this.apply(
|
|
||||||
new KYCSubmittedEvent(
|
|
||||||
this._userId,
|
|
||||||
kycInfo.realName,
|
|
||||||
kycInfo.idCardNumber,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
approveKYC(): void {
|
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) {
|
|
||||||
throw new DomainException('只有待审核状态才能通过KYC');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._kycStatus = KYCStatus.VERIFIED;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectKYC(reason: string): void {
|
|
||||||
if (this._kycStatus !== KYCStatus.PENDING) {
|
|
||||||
throw new DomainException('只有待审核状态才能拒绝KYC');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._kycStatus = KYCStatus.REJECTED;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
recordLogin(deviceId: string): void {
|
|
||||||
this.ensureActive();
|
|
||||||
|
|
||||||
if (!this._devices.has(deviceId)) {
|
|
||||||
throw new DomainException('设备未授权');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._devices.get(deviceId)!.updateActivity();
|
|
||||||
this._lastLoginAt = new Date();
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProfile(params: {
|
|
||||||
nickname?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
address?: string;
|
|
||||||
}): void {
|
|
||||||
this.ensureActive();
|
|
||||||
|
|
||||||
if (params.nickname !== undefined) {
|
|
||||||
if (params.nickname.trim().length < 2) {
|
|
||||||
throw new DomainException('昵称至少需要2个字符');
|
|
||||||
}
|
|
||||||
this._nickname = params.nickname;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.avatarUrl !== undefined) {
|
|
||||||
this._avatarUrl = params.avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.address !== undefined) {
|
|
||||||
this._address = params.address;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
freeze(reason: string): void {
|
|
||||||
if (this._status === AccountStatus.FROZEN) {
|
|
||||||
throw new DomainException('账户已冻结');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._status === AccountStatus.DEACTIVATED) {
|
|
||||||
throw new DomainException('账户已注销,无法冻结');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._status = AccountStatus.FROZEN;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
unfreeze(): void {
|
|
||||||
if (this._status !== AccountStatus.FROZEN) {
|
|
||||||
throw new DomainException('账户未冻结');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._status = AccountStatus.ACTIVE;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivate(): void {
|
|
||||||
if (this._status === AccountStatus.DEACTIVATED) {
|
|
||||||
throw new DomainException('账户已注销');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._status = AccountStatus.DEACTIVATED;
|
|
||||||
this._updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureActive(): void {
|
private ensureActive(): void {
|
||||||
if (this._status !== AccountStatus.ACTIVE) {
|
if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销');
|
||||||
throw new DomainException('账户已冻结或注销');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistence helpers
|
private addDomainEvent(event: DomainEvent): void {
|
||||||
toPersistenceData(): object {
|
this._domainEvents.push(event);
|
||||||
return {
|
}
|
||||||
userId: this._userId,
|
|
||||||
accountSequence: this._accountSequence.value,
|
clearDomainEvents(): void {
|
||||||
phoneNumber: this._phoneNumber?.value || null,
|
this._domainEvents = [];
|
||||||
nickname: this._nickname,
|
|
||||||
avatarUrl: this._avatarUrl,
|
|
||||||
inviterSequence: this._inviterSequence?.value || null,
|
|
||||||
referralCode: this._referralCode.value,
|
|
||||||
provinceCode: this._provinceCode,
|
|
||||||
cityCode: this._cityCode,
|
|
||||||
address: this._address,
|
|
||||||
kycStatus: this._kycStatus,
|
|
||||||
realName: this._kycInfo?.realName || null,
|
|
||||||
idCardNumber: this._kycInfo?.idCardNumber || null,
|
|
||||||
idCardFrontUrl: this._kycInfo?.idCardFrontUrl || null,
|
|
||||||
idCardBackUrl: this._kycInfo?.idCardBackUrl || null,
|
|
||||||
status: this._status,
|
|
||||||
lastLoginAt: this._lastLoginAt,
|
|
||||||
updatedAt: this._updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1,90 +1,167 @@
|
||||||
import { ChainType } from '../enums/chain-type.enum';
|
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 {
|
export class WalletAddress {
|
||||||
private constructor(
|
private readonly _addressId: AddressId;
|
||||||
private readonly _addressId: string,
|
private readonly _userId: UserId;
|
||||||
private readonly _userId: string,
|
private readonly _chainType: ChainType;
|
||||||
private readonly _chainType: ChainType,
|
private readonly _address: string;
|
||||||
private readonly _address: string,
|
private readonly _encryptedMnemonic: string;
|
||||||
private readonly _encryptedMnemonic: string,
|
private _status: AddressStatus;
|
||||||
private _status: 'ACTIVE' | 'DISABLED',
|
private readonly _boundAt: Date;
|
||||||
private readonly _boundAt: Date,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
static create(params: {
|
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;
|
addressId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
chainType: ChainType;
|
chainType: ChainType;
|
||||||
address: string;
|
address: string;
|
||||||
encryptedMnemonic: string;
|
encryptedMnemonic: string;
|
||||||
status?: 'ACTIVE' | 'DISABLED';
|
status: AddressStatus;
|
||||||
boundAt?: Date;
|
boundAt: Date;
|
||||||
}): WalletAddress {
|
}): WalletAddress {
|
||||||
return new WalletAddress(
|
return new WalletAddress(
|
||||||
params.addressId,
|
AddressId.create(params.addressId),
|
||||||
params.userId,
|
UserId.create(params.userId),
|
||||||
params.chainType,
|
params.chainType,
|
||||||
params.address,
|
params.address,
|
||||||
params.encryptedMnemonic,
|
params.encryptedMnemonic,
|
||||||
params.status || 'ACTIVE',
|
params.status,
|
||||||
params.boundAt || new Date(),
|
params.boundAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get addressId(): string {
|
|
||||||
return this._addressId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get userId(): string {
|
|
||||||
return this._userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get chainType(): ChainType {
|
|
||||||
return this._chainType;
|
|
||||||
}
|
|
||||||
|
|
||||||
get address(): string {
|
|
||||||
return this._address;
|
|
||||||
}
|
|
||||||
|
|
||||||
get encryptedMnemonic(): string {
|
|
||||||
return this._encryptedMnemonic;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status(): 'ACTIVE' | 'DISABLED' {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
get boundAt(): Date {
|
|
||||||
return this._boundAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
|
||||||
return this._status === 'ACTIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
disable(): void {
|
disable(): void {
|
||||||
this._status = 'DISABLED';
|
this._status = AddressStatus.DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(): void {
|
enable(): void {
|
||||||
this._status = 'ACTIVE';
|
this._status = AddressStatus.ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedAddress(): string {
|
decryptMnemonic(encryptionKey: string): Mnemonic {
|
||||||
if (this._address.length <= 12) {
|
if (!this._encryptedMnemonic) {
|
||||||
return this._address;
|
throw new DomainError('该地址没有加密助记词');
|
||||||
}
|
}
|
||||||
return `${this._address.slice(0, 8)}...${this._address.slice(-4)}`;
|
const mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey);
|
||||||
|
return Mnemonic.create(mnemonicStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(): object {
|
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
||||||
return {
|
const seed = mnemonic.toSeed();
|
||||||
addressId: this._addressId,
|
const config = CHAIN_CONFIG[chainType];
|
||||||
chainType: this._chainType,
|
|
||||||
address: this._address,
|
switch (chainType) {
|
||||||
status: this._status,
|
case ChainType.KAVA:
|
||||||
boundAt: this._boundAt.toISOString(),
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { DomainEvent } from './domain-event.base';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class DeviceAddedEvent extends DomainEvent {
|
export class DeviceAddedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly payload: {
|
||||||
public readonly accountSequence: number,
|
userId: string;
|
||||||
public readonly deviceId: string,
|
accountSequence: number;
|
||||||
public readonly deviceName: string,
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
@ -13,21 +15,4 @@ export class DeviceAddedEvent extends DomainEvent {
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'DeviceAdded';
|
return 'DeviceAdded';
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateId(): string {
|
|
||||||
return this.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get aggregateType(): string {
|
|
||||||
return 'UserAccount';
|
|
||||||
}
|
|
||||||
|
|
||||||
toPayload(): object {
|
|
||||||
return {
|
|
||||||
userId: this.userId,
|
|
||||||
accountSequence: this.accountSequence,
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
deviceName: this.deviceName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,174 @@
|
||||||
export * from './domain-event.base';
|
export abstract class DomainEvent {
|
||||||
export * from './user-account-created.event';
|
public readonly occurredAt: Date;
|
||||||
export * from './device-added.event';
|
public readonly eventId: string;
|
||||||
export * from './phone-number-bound.event';
|
|
||||||
export * from './kyc-submitted.event';
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { DomainEvent } from './domain-event.base';
|
import { DomainEvent } from './index';
|
||||||
|
|
||||||
export class UserAccountCreatedEvent extends DomainEvent {
|
export class UserAccountCreatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: string,
|
public readonly payload: {
|
||||||
public readonly accountSequence: number,
|
userId: string;
|
||||||
public readonly initialDeviceId: string,
|
accountSequence: number;
|
||||||
public readonly inviterSequence: number | null,
|
phoneNumber: string;
|
||||||
public readonly provinceCode: string,
|
initialDeviceId: string;
|
||||||
public readonly cityCode: string,
|
inviterSequence: number | null;
|
||||||
public readonly referralCode: string,
|
province: string;
|
||||||
|
city: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
@ -16,24 +19,4 @@ export class UserAccountCreatedEvent extends DomainEvent {
|
||||||
get eventType(): string {
|
get eventType(): string {
|
||||||
return 'UserAccountCreated';
|
return 'UserAccountCreated';
|
||||||
}
|
}
|
||||||
|
|
||||||
get aggregateId(): string {
|
|
||||||
return this.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get aggregateType(): string {
|
|
||||||
return 'UserAccount';
|
|
||||||
}
|
|
||||||
|
|
||||||
toPayload(): object {
|
|
||||||
return {
|
|
||||||
userId: this.userId,
|
|
||||||
accountSequence: this.accountSequence,
|
|
||||||
initialDeviceId: this.initialDeviceId,
|
|
||||||
inviterSequence: this.inviterSequence,
|
|
||||||
provinceCode: this.provinceCode,
|
|
||||||
cityCode: this.cityCode,
|
|
||||||
referralCode: this.referralCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,30 @@
|
||||||
import { UserAccount } from '../aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { AccountSequence } from '../value-objects/account-sequence.vo';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { PhoneNumber } from '../value-objects/phone-number.vo';
|
import {
|
||||||
import { ReferralCode } from '../value-objects/referral-code.vo';
|
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus,
|
||||||
import { ChainType } from '../enums/chain-type.enum';
|
} from '@/domain/value-objects';
|
||||||
import { WalletAddress } from '../entities/wallet-address.entity';
|
|
||||||
|
|
||||||
export interface IUserAccountRepository {
|
export interface Pagination {
|
||||||
/**
|
page: number;
|
||||||
* 保存用户账户
|
limit: number;
|
||||||
*/
|
}
|
||||||
|
|
||||||
|
export interface UserAccountRepository {
|
||||||
save(account: UserAccount): Promise<void>;
|
save(account: UserAccount): Promise<void>;
|
||||||
|
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
||||||
/**
|
findById(userId: UserId): Promise<UserAccount | null>;
|
||||||
* 保存钱包地址
|
|
||||||
*/
|
|
||||||
saveWallets(userId: string, wallets: WalletAddress[]): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据用户ID查找账户
|
|
||||||
*/
|
|
||||||
findById(userId: string): Promise<UserAccount | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据账户序列号查找账户
|
|
||||||
*/
|
|
||||||
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
findByAccountSequence(sequence: AccountSequence): Promise<UserAccount | null>;
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据设备ID查找账户
|
|
||||||
*/
|
|
||||||
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
findByDeviceId(deviceId: string): Promise<UserAccount | null>;
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据手机号查找账户
|
|
||||||
*/
|
|
||||||
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null>;
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据推荐码查找账户
|
|
||||||
*/
|
|
||||||
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null>;
|
||||||
|
findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null>;
|
||||||
/**
|
getMaxAccountSequence(): Promise<AccountSequence | null>;
|
||||||
* 根据钱包地址查找账户
|
|
||||||
*/
|
|
||||||
findByWalletAddress(
|
|
||||||
chainType: ChainType,
|
|
||||||
address: string,
|
|
||||||
): Promise<UserAccount | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取最大账户序列号
|
|
||||||
*/
|
|
||||||
getNextAccountSequence(): Promise<AccountSequence>;
|
getNextAccountSequence(): Promise<AccountSequence>;
|
||||||
|
findUsers(
|
||||||
/**
|
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string },
|
||||||
* 检查设备ID是否已存在
|
pagination?: Pagination,
|
||||||
*/
|
): Promise<UserAccount[]>;
|
||||||
existsByDeviceId(deviceId: string): Promise<boolean>;
|
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查手机号是否已存在
|
|
||||||
*/
|
|
||||||
existsByPhoneNumber(phoneNumber: PhoneNumber): Promise<boolean>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查推荐码是否存在
|
|
||||||
*/
|
|
||||||
existsByReferralCode(referralCode: ReferralCode): Promise<boolean>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除用户的指定设备
|
|
||||||
*/
|
|
||||||
removeDevice(userId: string, deviceId: string): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,121 @@
|
||||||
export * from './user-validator.service';
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,51 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { PhoneNumber } from '../value-objects/phone-number.vo';
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { ReferralCode } from '../value-objects/referral-code.vo';
|
import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||||
import { ChainType } from '../enums/chain-type.enum';
|
|
||||||
import {
|
|
||||||
IUserAccountRepository,
|
|
||||||
USER_ACCOUNT_REPOSITORY,
|
|
||||||
} from '../repositories/user-account.repository.interface';
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
export class ValidationResult {
|
||||||
isValid: boolean;
|
private constructor(
|
||||||
errorMessage?: string;
|
public readonly isValid: boolean,
|
||||||
data?: any;
|
public readonly errorMessage: string | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static success(): ValidationResult {
|
||||||
|
return new ValidationResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static failure(message: string): ValidationResult {
|
||||||
|
return new ValidationResult(false, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserValidatorService {
|
export class UserValidatorService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly userRepository: IUserAccountRepository,
|
private readonly repository: UserAccountRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateDeviceId(deviceId: string): Promise<ValidationResult> {
|
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
||||||
if (!deviceId || deviceId.trim().length === 0) {
|
const existing = await this.repository.findByPhoneNumber(phoneNumber);
|
||||||
return { isValid: false, errorMessage: '设备ID不能为空' };
|
if (existing) return ValidationResult.failure('该手机号已注册');
|
||||||
}
|
return ValidationResult.success();
|
||||||
|
|
||||||
const exists = await this.userRepository.existsByDeviceId(deviceId);
|
|
||||||
if (exists) {
|
|
||||||
return { isValid: false, errorMessage: '该设备已创建账户' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validatePhoneNumber(phoneNumber: PhoneNumber): Promise<ValidationResult> {
|
async validateDeviceId(deviceId: string): Promise<ValidationResult> {
|
||||||
const exists = await this.userRepository.existsByPhoneNumber(phoneNumber);
|
const existing = await this.repository.findByDeviceId(deviceId);
|
||||||
if (exists) {
|
if (existing) return ValidationResult.failure('该设备已创建账户');
|
||||||
return { isValid: false, errorMessage: '该手机号已注册' };
|
return ValidationResult.success();
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
async validateReferralCode(referralCode: ReferralCode): Promise<ValidationResult> {
|
||||||
const inviter = await this.userRepository.findByReferralCode(referralCode);
|
const inviter = await this.repository.findByReferralCode(referralCode);
|
||||||
|
if (!inviter) return ValidationResult.failure('推荐码不存在');
|
||||||
if (!inviter) {
|
if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销');
|
||||||
return { isValid: false, errorMessage: '推荐码不存在' };
|
return ValidationResult.success();
|
||||||
}
|
|
||||||
|
|
||||||
if (!inviter.isActive) {
|
|
||||||
return { isValid: false, errorMessage: '推荐人账户已冻结或注销' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: true,
|
|
||||||
data: {
|
|
||||||
inviterUserId: inviter.userId,
|
|
||||||
inviterSequence: inviter.accountSequence.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateWalletAddress(
|
async validateWalletAddress(chainType: ChainType, address: string): Promise<ValidationResult> {
|
||||||
chainType: ChainType,
|
const existing = await this.repository.findByWalletAddress(chainType, address);
|
||||||
address: string,
|
if (existing) return ValidationResult.failure('该地址已被其他账户绑定');
|
||||||
): Promise<ValidationResult> {
|
return ValidationResult.success();
|
||||||
const existing = await this.userRepository.findByWalletAddress(
|
|
||||||
chainType,
|
|
||||||
address,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return { isValid: false, errorMessage: '该地址已被其他账户绑定' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class AccountSequence {
|
export class AccountSequence {
|
||||||
private readonly _value: number;
|
constructor(public readonly value: number) {
|
||||||
|
if (value <= 0) throw new DomainError('账户序列号必须大于0');
|
||||||
private constructor(value: number) {
|
|
||||||
if (!Number.isInteger(value) || value <= 0) {
|
|
||||||
throw new DomainException('账户序列号必须是大于0的整数');
|
|
||||||
}
|
|
||||||
this._value = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: number): AccountSequence {
|
static create(value: number): AccountSequence {
|
||||||
|
|
@ -18,16 +13,7 @@ export class AccountSequence {
|
||||||
return new AccountSequence(current.value + 1);
|
return new AccountSequence(current.value + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): number {
|
|
||||||
return this._value;
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: AccountSequence): boolean {
|
equals(other: AccountSequence): boolean {
|
||||||
if (!other) return false;
|
return this.value === other.value;
|
||||||
return this._value === other.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return String(this._value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ export class DeviceInfo {
|
||||||
public readonly addedAt: Date,
|
public readonly addedAt: Date,
|
||||||
lastActiveAt: Date,
|
lastActiveAt: Date,
|
||||||
) {
|
) {
|
||||||
if (!deviceId || deviceId.trim().length === 0) {
|
|
||||||
throw new Error('设备ID不能为空');
|
|
||||||
}
|
|
||||||
this._lastActiveAt = lastActiveAt;
|
this._lastActiveAt = lastActiveAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,19 +17,4 @@ export class DeviceInfo {
|
||||||
updateActivity(): void {
|
updateActivity(): void {
|
||||||
this._lastActiveAt = new Date();
|
this._lastActiveAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive(thresholdDays: number = 30): boolean {
|
|
||||||
const threshold = new Date();
|
|
||||||
threshold.setDate(threshold.getDate() - thresholdDays);
|
|
||||||
return this._lastActiveAt >= threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): object {
|
|
||||||
return {
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
deviceName: this.deviceName,
|
|
||||||
addedAt: this.addedAt.toISOString(),
|
|
||||||
lastActiveAt: this._lastActiveAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,262 @@
|
||||||
export * from './account-sequence.vo';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
export * from './phone-number.vo';
|
import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
||||||
export * from './mnemonic.vo';
|
import * as bip39 from '@scure/bip39';
|
||||||
export * from './device-info.vo';
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
export * from './referral-code.vo';
|
|
||||||
export * from './kyc-info.vo';
|
// ============ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class KYCInfo {
|
export class KYCInfo {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -7,68 +7,19 @@ export class KYCInfo {
|
||||||
public readonly idCardFrontUrl: string,
|
public readonly idCardFrontUrl: string,
|
||||||
public readonly idCardBackUrl: string,
|
public readonly idCardBackUrl: string,
|
||||||
) {
|
) {
|
||||||
this.validateRealName(realName);
|
if (!realName || realName.length < 2) {
|
||||||
this.validateIdCardNumber(idCardNumber);
|
throw new DomainError('真实姓名不合法');
|
||||||
this.validateUrl(idCardFrontUrl, '身份证正面图片');
|
|
||||||
this.validateUrl(idCardBackUrl, '身份证反面图片');
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateRealName(name: string): void {
|
|
||||||
if (!name || name.trim().length < 2) {
|
|
||||||
throw new DomainException('真实姓名至少需要2个字符');
|
|
||||||
}
|
}
|
||||||
if (name.length > 50) {
|
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 DomainException('真实姓名不能超过50个字符');
|
throw new DomainError('身份证号格式错误');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateIdCardNumber(idCard: string): void {
|
static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo {
|
||||||
// 18位身份证号校验
|
return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl);
|
||||||
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/;
|
|
||||||
if (!pattern.test(idCard)) {
|
|
||||||
throw new DomainException('身份证号格式错误');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateUrl(url: string, fieldName: string): void {
|
|
||||||
if (!url || url.trim().length === 0) {
|
|
||||||
throw new DomainException(`${fieldName}URL不能为空`);
|
|
||||||
}
|
|
||||||
// 简单URL格式校验
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
throw new DomainException(`${fieldName}URL格式错误`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static create(params: {
|
|
||||||
realName: string;
|
|
||||||
idCardNumber: string;
|
|
||||||
idCardFrontUrl: string;
|
|
||||||
idCardBackUrl: string;
|
|
||||||
}): KYCInfo {
|
|
||||||
return new KYCInfo(
|
|
||||||
params.realName,
|
|
||||||
params.idCardNumber,
|
|
||||||
params.idCardFrontUrl,
|
|
||||||
params.idCardBackUrl,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedIdCardNumber(): string {
|
maskedIdCardNumber(): string {
|
||||||
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
|
||||||
}
|
}
|
||||||
|
|
||||||
maskedRealName(): string {
|
|
||||||
if (this.realName.length <= 1) {
|
|
||||||
return '*';
|
|
||||||
}
|
|
||||||
return this.realName[0] + '*'.repeat(this.realName.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): object {
|
|
||||||
return {
|
|
||||||
realName: this.maskedRealName(),
|
|
||||||
idCardNumber: this.maskedIdCardNumber(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
import * as bip39 from 'bip39';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
import * as bip39 from '@scure/bip39';
|
||||||
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
|
|
||||||
export class Mnemonic {
|
export class Mnemonic {
|
||||||
private readonly _value: string;
|
constructor(public readonly value: string) {
|
||||||
|
if (!bip39.validateMnemonic(value, wordlist)) {
|
||||||
private constructor(value: string) {
|
throw new DomainError('助记词格式错误');
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!bip39.validateMnemonic(normalized)) {
|
|
||||||
throw new DomainException('助记词格式错误,必须是有效的BIP39助记词');
|
|
||||||
}
|
}
|
||||||
this._value = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static generate(): Mnemonic {
|
static generate(): Mnemonic {
|
||||||
const mnemonic = bip39.generateMnemonic(128); // 12 words
|
const mnemonic = bip39.generateMnemonic(wordlist, 128);
|
||||||
return new Mnemonic(mnemonic);
|
return new Mnemonic(mnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,29 +18,15 @@ export class Mnemonic {
|
||||||
return new Mnemonic(value);
|
return new Mnemonic(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): string {
|
toSeed(): Uint8Array {
|
||||||
return this._value;
|
return bip39.mnemonicToSeedSync(this.value);
|
||||||
}
|
|
||||||
|
|
||||||
toSeed(): Buffer {
|
|
||||||
return bip39.mnemonicToSeedSync(this._value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getWords(): string[] {
|
getWords(): string[] {
|
||||||
return this._value.split(' ');
|
return this.value.split(' ');
|
||||||
}
|
|
||||||
|
|
||||||
getWordCount(): number {
|
|
||||||
return this.getWords().length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Mnemonic): boolean {
|
equals(other: Mnemonic): boolean {
|
||||||
if (!other) return false;
|
return this.value === other.value;
|
||||||
return this._value === other.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不暴露原始值的toString
|
|
||||||
toString(): string {
|
|
||||||
return '[MNEMONIC HIDDEN]';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,21 @@
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class PhoneNumber {
|
export class PhoneNumber {
|
||||||
private readonly _value: string;
|
constructor(public readonly value: string) {
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||||
private constructor(value: string) {
|
throw new DomainError('手机号格式错误');
|
||||||
const normalized = value.replace(/\s+/g, '');
|
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
|
||||||
throw new DomainException('手机号格式错误,必须是11位中国大陆手机号');
|
|
||||||
}
|
}
|
||||||
this._value = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: string): PhoneNumber {
|
static create(value: string): PhoneNumber {
|
||||||
return new PhoneNumber(value);
|
return new PhoneNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): string {
|
equals(other: PhoneNumber): boolean {
|
||||||
return this._value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
masked(): string {
|
masked(): string {
|
||||||
return this._value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: PhoneNumber): boolean {
|
|
||||||
if (!other) return false;
|
|
||||||
return this._value === other.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.masked();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import { DomainException } from '@shared/exceptions/domain.exception';
|
import { DomainError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
export class ReferralCode {
|
export class ReferralCode {
|
||||||
private readonly _value: string;
|
constructor(public readonly value: string) {
|
||||||
|
if (!/^[A-Z0-9]{6}$/.test(value)) {
|
||||||
private constructor(value: string) {
|
throw new DomainError('推荐码格式错误');
|
||||||
const normalized = value.toUpperCase().trim();
|
|
||||||
if (!/^[A-Z0-9]{6}$/.test(normalized)) {
|
|
||||||
throw new DomainException('推荐码格式错误,必须是6位字母数字组合');
|
|
||||||
}
|
}
|
||||||
this._value = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static generate(): ReferralCode {
|
static generate(): ReferralCode {
|
||||||
|
|
@ -21,19 +17,10 @@ export class ReferralCode {
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(value: string): ReferralCode {
|
static create(value: string): ReferralCode {
|
||||||
return new ReferralCode(value);
|
return new ReferralCode(value.toUpperCase());
|
||||||
}
|
|
||||||
|
|
||||||
get value(): string {
|
|
||||||
return this._value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: ReferralCode): boolean {
|
equals(other: ReferralCode): boolean {
|
||||||
if (!other) return false;
|
return this.value === other.value;
|
||||||
return this._value === other.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this._value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WalletGeneratorServiceImpl } from './wallet-generator.service.impl';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [WalletGeneratorServiceImpl],
|
||||||
|
exports: [WalletGeneratorServiceImpl],
|
||||||
|
})
|
||||||
|
export class BlockchainModule {}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/services/identity-service/src/infrastructure/external/sms/sms.module.ts
vendored
Normal file
8
backend/services/identity-service/src/infrastructure/external/sms/sms.module.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SmsService],
|
||||||
|
exports: [SmsService],
|
||||||
|
})
|
||||||
|
export class SmsModule {}
|
||||||
|
|
@ -1,150 +1,23 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { RedisService } from '../../redis/redis.service';
|
|
||||||
import { ApplicationException } from '@shared/exceptions/application.exception';
|
|
||||||
|
|
||||||
export enum SmsType {
|
|
||||||
REGISTER = 'REGISTER',
|
|
||||||
LOGIN = 'LOGIN',
|
|
||||||
BIND = 'BIND',
|
|
||||||
RECOVER = 'RECOVER',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
constructor(private readonly configService: ConfigService) {}
|
||||||
private readonly codeLength = 6;
|
|
||||||
private readonly codeTtl = 300; // 5分钟
|
|
||||||
private readonly sendInterval = 60; // 发送间隔60秒
|
|
||||||
|
|
||||||
constructor(
|
async sendSms(phoneNumber: string, content: string): Promise<boolean> {
|
||||||
private readonly configService: ConfigService,
|
const apiUrl = this.configService.get<string>('SMS_API_URL');
|
||||||
private readonly redisService: RedisService,
|
const apiKey = this.configService.get<string>('SMS_API_KEY');
|
||||||
) {}
|
|
||||||
|
|
||||||
async sendVerificationCode(
|
// 实际项目中调用SMS API
|
||||||
phoneNumber: string,
|
console.log(`[SMS] Sending to ${phoneNumber}: ${content}`);
|
||||||
type: SmsType,
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
// 检查发送间隔
|
|
||||||
const intervalKey = `sms:interval:${type}:${phoneNumber}`;
|
|
||||||
const hasInterval = await this.redisService.exists(intervalKey);
|
|
||||||
|
|
||||||
if (hasInterval) {
|
|
||||||
const ttl = await this.redisService.ttl(intervalKey);
|
|
||||||
throw new ApplicationException(`请${ttl}秒后再试`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成验证码
|
|
||||||
const code = this.generateCode();
|
|
||||||
|
|
||||||
// 发送短信(调用第三方服务)
|
|
||||||
try {
|
|
||||||
await this.sendSms(phoneNumber, code, type);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to send SMS to ${phoneNumber}`, error);
|
|
||||||
throw new ApplicationException('短信发送失败,请稍后重试');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存验证码到Redis
|
|
||||||
await this.redisService.setSmsCode(phoneNumber, type, code, this.codeTtl);
|
|
||||||
|
|
||||||
// 设置发送间隔
|
|
||||||
await this.redisService.set(intervalKey, '1', this.sendInterval);
|
|
||||||
|
|
||||||
this.logger.log(`SMS code sent to ${phoneNumber} for ${type}`);
|
|
||||||
|
|
||||||
return { success: true, message: '验证码已发送' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyCode(
|
|
||||||
phoneNumber: string,
|
|
||||||
type: SmsType,
|
|
||||||
code: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const storedCode = await this.redisService.getSmsCode(phoneNumber, type);
|
|
||||||
|
|
||||||
if (!storedCode) {
|
|
||||||
throw new ApplicationException('验证码已过期,请重新获取');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storedCode !== code) {
|
|
||||||
throw new ApplicationException('验证码错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证成功后删除验证码
|
|
||||||
await this.redisService.deleteSmsCode(phoneNumber, type);
|
|
||||||
|
|
||||||
this.logger.log(`SMS code verified for ${phoneNumber}`);
|
|
||||||
|
|
||||||
|
// 模拟发送成功
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCode(): string {
|
async sendVerificationCode(phoneNumber: string, code: string): Promise<boolean> {
|
||||||
let code = '';
|
const content = `您的验证码是${code},5分钟内有效。`;
|
||||||
for (let i = 0; i < this.codeLength; i++) {
|
return this.sendSms(phoneNumber, content);
|
||||||
code += Math.floor(Math.random() * 10).toString();
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendSms(
|
|
||||||
phoneNumber: string,
|
|
||||||
code: string,
|
|
||||||
type: SmsType,
|
|
||||||
): Promise<void> {
|
|
||||||
const provider = this.configService.get('SMS_PROVIDER', 'aliyun');
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case 'aliyun':
|
|
||||||
await this.sendAliyunSms(phoneNumber, code, type);
|
|
||||||
break;
|
|
||||||
case 'tencent':
|
|
||||||
await this.sendTencentSms(phoneNumber, code, type);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// 开发环境:打印验证码
|
|
||||||
if (this.configService.get('NODE_ENV') === 'development') {
|
|
||||||
this.logger.debug(`[DEV] SMS Code for ${phoneNumber}: ${code}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported SMS provider: ${provider}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendAliyunSms(
|
|
||||||
phoneNumber: string,
|
|
||||||
code: string,
|
|
||||||
type: SmsType,
|
|
||||||
): Promise<void> {
|
|
||||||
const accessKeyId = this.configService.get('SMS_ACCESS_KEY_ID');
|
|
||||||
const accessKeySecret = this.configService.get('SMS_ACCESS_KEY_SECRET');
|
|
||||||
const signName = this.configService.get('SMS_SIGN_NAME');
|
|
||||||
const templateCode = this.configService.get('SMS_TEMPLATE_CODE');
|
|
||||||
|
|
||||||
if (!accessKeyId || !accessKeySecret) {
|
|
||||||
if (this.configService.get('NODE_ENV') === 'development') {
|
|
||||||
this.logger.debug(`[DEV] Aliyun SMS Code for ${phoneNumber}: ${code}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('Aliyun SMS credentials not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实际调用阿里云短信API
|
|
||||||
// 这里使用简化实现,实际项目中应该使用@alicloud/dysmsapi20170525
|
|
||||||
this.logger.log(`Sending Aliyun SMS to ${phoneNumber}, template: ${templateCode}`);
|
|
||||||
|
|
||||||
// TODO: 实现阿里云短信发送
|
|
||||||
// const client = new Dysmsapi({...});
|
|
||||||
// await client.sendSms({...});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendTencentSms(
|
|
||||||
phoneNumber: string,
|
|
||||||
code: string,
|
|
||||||
type: SmsType,
|
|
||||||
): Promise<void> {
|
|
||||||
// TODO: 实现腾讯云短信发送
|
|
||||||
this.logger.log(`Sending Tencent SMS to ${phoneNumber}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1,241 +1,53 @@
|
||||||
import {
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
Injectable,
|
import { ConfigService } from '@nestjs/config';
|
||||||
Inject,
|
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||||
OnModuleInit,
|
import { DomainEvent } from '@/domain/events';
|
||||||
OnModuleDestroy,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ClientKafka } from '@nestjs/microservices';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { KAFKA_SERVICE } from './kafka.module';
|
|
||||||
|
|
||||||
export interface DomainEventMessage {
|
|
||||||
eventId: string;
|
|
||||||
eventType: string;
|
|
||||||
aggregateId: string;
|
|
||||||
aggregateType: string;
|
|
||||||
payload: any;
|
|
||||||
occurredAt: string;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IDENTITY_TOPICS = {
|
|
||||||
USER_ACCOUNT_CREATED: 'identity.user-account.created',
|
|
||||||
DEVICE_ADDED: 'identity.device.added',
|
|
||||||
DEVICE_REMOVED: 'identity.device.removed',
|
|
||||||
PHONE_BOUND: 'identity.phone.bound',
|
|
||||||
KYC_SUBMITTED: 'identity.kyc.submitted',
|
|
||||||
KYC_APPROVED: 'identity.kyc.approved',
|
|
||||||
KYC_REJECTED: 'identity.kyc.rejected',
|
|
||||||
ACCOUNT_FROZEN: 'identity.account.frozen',
|
|
||||||
ACCOUNT_UNFROZEN: 'identity.account.unfrozen',
|
|
||||||
ACCOUNT_DEACTIVATED: 'identity.account.deactivated',
|
|
||||||
WALLET_BOUND: 'identity.wallet.bound',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(EventPublisherService.name);
|
private kafka: Kafka;
|
||||||
private isConnected = false;
|
private producer: Producer;
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly configService: ConfigService) {
|
||||||
@Inject(KAFKA_SERVICE)
|
this.kafka = new Kafka({
|
||||||
private readonly kafkaClient: ClientKafka,
|
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(): Promise<void> {
|
async onModuleInit() {
|
||||||
try {
|
await this.producer.connect();
|
||||||
await this.kafkaClient.connect();
|
}
|
||||||
this.isConnected = true;
|
|
||||||
this.logger.log('Kafka producer connected');
|
async onModuleDestroy() {
|
||||||
} catch (error) {
|
await this.producer.disconnect();
|
||||||
this.logger.error('Failed to connect Kafka producer', error);
|
}
|
||||||
// 不抛出错误,允许服务在没有Kafka的情况下启动
|
|
||||||
this.isConnected = false;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
if (this.isConnected) {
|
|
||||||
await this.kafkaClient.close();
|
|
||||||
this.logger.log('Kafka producer disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish(topic: string, event: any): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
this.logger.warn(`Kafka not connected, skipping event publish to ${topic}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = this.serializeEvent(event);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.kafkaClient
|
|
||||||
.emit(topic, {
|
|
||||||
key: message.aggregateId,
|
|
||||||
value: JSON.stringify(message),
|
|
||||||
headers: {
|
|
||||||
eventType: message.eventType,
|
|
||||||
eventId: message.eventId,
|
|
||||||
occurredAt: message.occurredAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
this.logger.debug(`Event published to ${topic}: ${message.eventId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to publish event to ${topic}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishUserAccountCreated(event: {
|
|
||||||
userId: string;
|
|
||||||
accountSequence: number;
|
|
||||||
initialDeviceId: string;
|
|
||||||
inviterSequence: number | null;
|
|
||||||
provinceCode: string;
|
|
||||||
cityCode: string;
|
|
||||||
referralCode: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.USER_ACCOUNT_CREATED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'UserAccountCreated',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishDeviceAdded(event: {
|
|
||||||
userId: string;
|
|
||||||
accountSequence: number;
|
|
||||||
deviceId: string;
|
|
||||||
deviceName: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.DEVICE_ADDED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'DeviceAdded',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishDeviceRemoved(event: {
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.DEVICE_REMOVED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'DeviceRemoved',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishPhoneBound(event: {
|
|
||||||
userId: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.PHONE_BOUND, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'PhoneNumberBound',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishKYCSubmitted(event: {
|
|
||||||
userId: string;
|
|
||||||
realName: string;
|
|
||||||
idCardNumber: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.KYC_SUBMITTED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'KYCSubmitted',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishKYCApproved(event: { userId: string }): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.KYC_APPROVED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'KYCApproved',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishKYCRejected(event: {
|
|
||||||
userId: string;
|
|
||||||
reason: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.KYC_REJECTED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'KYCRejected',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishAccountFrozen(event: {
|
|
||||||
userId: string;
|
|
||||||
reason: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.ACCOUNT_FROZEN, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'AccountFrozen',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishAccountUnfrozen(event: { userId: string }): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.ACCOUNT_UNFROZEN, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'AccountUnfrozen',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishAccountDeactivated(event: { userId: string }): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.ACCOUNT_DEACTIVATED, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'AccountDeactivated',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publishWalletBound(event: {
|
|
||||||
userId: string;
|
|
||||||
chainType: string;
|
|
||||||
address: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.publish(IDENTITY_TOPICS.WALLET_BOUND, {
|
|
||||||
aggregateId: event.userId,
|
|
||||||
aggregateType: 'UserAccount',
|
|
||||||
eventType: 'WalletBound',
|
|
||||||
payload: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeEvent(event: {
|
|
||||||
aggregateId: string;
|
|
||||||
aggregateType: string;
|
|
||||||
eventType: string;
|
|
||||||
payload: any;
|
|
||||||
}): DomainEventMessage {
|
|
||||||
return {
|
|
||||||
eventId: uuidv4(),
|
|
||||||
eventType: event.eventType,
|
|
||||||
aggregateId: event.aggregateId,
|
|
||||||
aggregateType: event.aggregateType,
|
|
||||||
payload: event.payload,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
version: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KafkaModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,8 @@
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
|
||||||
import { EventPublisherService } from './event-publisher.service';
|
import { EventPublisherService } from './event-publisher.service';
|
||||||
import { EventConsumerController } from './event-consumer.controller';
|
|
||||||
import { DeadLetterService } from './dead-letter.service';
|
|
||||||
import { EventRetryService } from './event-retry.service';
|
|
||||||
|
|
||||||
export const KAFKA_SERVICE = 'KAFKA_SERVICE';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
providers: [EventPublisherService],
|
||||||
ClientsModule.registerAsync([
|
|
||||||
{
|
|
||||||
name: KAFKA_SERVICE,
|
|
||||||
imports: [ConfigModule],
|
|
||||||
useFactory: (configService: ConfigService) => ({
|
|
||||||
transport: Transport.KAFKA,
|
|
||||||
options: {
|
|
||||||
client: {
|
|
||||||
clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'),
|
|
||||||
brokers: configService
|
|
||||||
.get('KAFKA_BROKERS', 'localhost:9092')
|
|
||||||
.split(','),
|
|
||||||
},
|
|
||||||
consumer: {
|
|
||||||
groupId: configService.get(
|
|
||||||
'KAFKA_GROUP_ID',
|
|
||||||
'identity-service-group',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
producer: {
|
|
||||||
allowAutoTopicCreation: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
inject: [ConfigService],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [EventConsumerController],
|
|
||||||
providers: [EventPublisherService, DeadLetterService, EventRetryService],
|
|
||||||
exports: [EventPublisherService],
|
exports: [EventPublisherService],
|
||||||
})
|
})
|
||||||
export class KafkaModule {}
|
export class KafkaModule {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export interface UserDeviceEntity {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string | null;
|
||||||
|
addedAt: Date;
|
||||||
|
lastActiveAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface WalletAddressEntity {
|
||||||
|
addressId: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
chainType: string;
|
||||||
|
address: string;
|
||||||
|
encryptedMnemonic: string | null;
|
||||||
|
status: string;
|
||||||
|
boundAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -1,142 +1,57 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
UserAccount as PrismaUserAccount,
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
UserDevice as PrismaUserDevice,
|
import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
|
||||||
WalletAddress as PrismaWalletAddress,
|
import { UserAccountEntity } from '../entities/user-account.entity';
|
||||||
} from '@prisma/client';
|
|
||||||
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
|
|
||||||
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
|
|
||||||
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
|
|
||||||
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
|
|
||||||
import { DeviceInfo } from '@domain/value-objects/device-info.vo';
|
|
||||||
import { KYCInfo } from '@domain/value-objects/kyc-info.vo';
|
|
||||||
import { WalletAddress } from '@domain/entities/wallet-address.entity';
|
|
||||||
import { ChainType } from '@domain/enums/chain-type.enum';
|
|
||||||
import { KYCStatus } from '@domain/enums/kyc-status.enum';
|
|
||||||
import { AccountStatus } from '@domain/enums/account-status.enum';
|
|
||||||
|
|
||||||
export type UserAccountWithRelations = PrismaUserAccount & {
|
|
||||||
devices: PrismaUserDevice[];
|
|
||||||
walletAddresses: PrismaWalletAddress[];
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAccountMapper {
|
export class UserAccountMapper {
|
||||||
toDomain(raw: UserAccountWithRelations): UserAccount {
|
toDomain(entity: UserAccountEntity): UserAccount {
|
||||||
// Map devices
|
const devices = (entity.devices || []).map(
|
||||||
const devices = new Map<string, DeviceInfo>();
|
(d) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt),
|
||||||
for (const device of raw.devices) {
|
);
|
||||||
devices.set(
|
|
||||||
device.deviceId,
|
|
||||||
new DeviceInfo(
|
|
||||||
device.deviceId,
|
|
||||||
device.deviceName || '未命名设备',
|
|
||||||
device.addedAt,
|
|
||||||
device.lastActiveAt,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map wallet addresses
|
const wallets = (entity.walletAddresses || []).map((w) =>
|
||||||
const walletAddresses = new Map<ChainType, WalletAddress>();
|
WalletAddress.reconstruct({
|
||||||
for (const wallet of raw.walletAddresses) {
|
addressId: w.addressId.toString(),
|
||||||
walletAddresses.set(
|
userId: w.userId.toString(),
|
||||||
wallet.chainType as ChainType,
|
chainType: w.chainType as ChainType,
|
||||||
WalletAddress.create({
|
address: w.address,
|
||||||
addressId: String(wallet.id),
|
encryptedMnemonic: w.encryptedMnemonic || '',
|
||||||
userId: String(wallet.userId),
|
status: w.status as AddressStatus,
|
||||||
chainType: wallet.chainType as ChainType,
|
boundAt: w.boundAt,
|
||||||
address: wallet.address,
|
}),
|
||||||
encryptedMnemonic: wallet.encryptedMnemonic || '',
|
);
|
||||||
status: wallet.status as 'ACTIVE' | 'DISABLED',
|
|
||||||
boundAt: wallet.boundAt,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map KYC info
|
|
||||||
const kycInfo =
|
const kycInfo =
|
||||||
raw.realName && raw.idCardNumber && raw.idCardFrontUrl && raw.idCardBackUrl
|
entity.realName && entity.idCardNumber
|
||||||
? KYCInfo.create({
|
? KYCInfo.create({
|
||||||
realName: raw.realName,
|
realName: entity.realName,
|
||||||
idCardNumber: raw.idCardNumber,
|
idCardNumber: entity.idCardNumber,
|
||||||
idCardFrontUrl: raw.idCardFrontUrl,
|
idCardFrontUrl: entity.idCardFrontUrl || '',
|
||||||
idCardBackUrl: raw.idCardBackUrl,
|
idCardBackUrl: entity.idCardBackUrl || '',
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return UserAccount.fromPersistence({
|
return UserAccount.reconstruct({
|
||||||
userId: String(raw.id),
|
userId: entity.userId.toString(),
|
||||||
accountSequence: AccountSequence.create(Number(raw.accountSequence)),
|
accountSequence: Number(entity.accountSequence),
|
||||||
devices,
|
devices,
|
||||||
phoneNumber: raw.phoneNumber ? PhoneNumber.create(raw.phoneNumber) : null,
|
phoneNumber: entity.phoneNumber,
|
||||||
nickname: raw.nickname,
|
nickname: entity.nickname,
|
||||||
avatarUrl: raw.avatarUrl,
|
avatarUrl: entity.avatarUrl,
|
||||||
inviterSequence: raw.inviterSequence
|
inviterSequence: entity.inviterSequence ? Number(entity.inviterSequence) : null,
|
||||||
? AccountSequence.create(Number(raw.inviterSequence))
|
referralCode: entity.referralCode,
|
||||||
: null,
|
province: entity.provinceCode,
|
||||||
referralCode: ReferralCode.create(raw.referralCode),
|
city: entity.cityCode,
|
||||||
provinceCode: raw.provinceCode,
|
address: entity.address,
|
||||||
cityCode: raw.cityCode,
|
walletAddresses: wallets,
|
||||||
address: raw.address,
|
|
||||||
walletAddresses,
|
|
||||||
kycInfo,
|
kycInfo,
|
||||||
kycStatus: raw.kycStatus as KYCStatus,
|
kycStatus: entity.kycStatus as KYCStatus,
|
||||||
status: raw.status as AccountStatus,
|
status: entity.status as AccountStatus,
|
||||||
registeredAt: raw.registeredAt,
|
registeredAt: entity.registeredAt,
|
||||||
lastLoginAt: raw.lastLoginAt,
|
lastLoginAt: entity.lastLoginAt,
|
||||||
updatedAt: raw.updatedAt,
|
updatedAt: entity.updatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toPersistence(account: UserAccount): any {
|
|
||||||
const data = account.toPersistenceData() as any;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: BigInt(data.userId),
|
|
||||||
accountSequence: BigInt(data.accountSequence),
|
|
||||||
phoneNumber: data.phoneNumber,
|
|
||||||
nickname: data.nickname,
|
|
||||||
avatarUrl: data.avatarUrl,
|
|
||||||
inviterSequence: data.inviterSequence
|
|
||||||
? BigInt(data.inviterSequence)
|
|
||||||
: null,
|
|
||||||
referralCode: data.referralCode,
|
|
||||||
provinceCode: data.provinceCode,
|
|
||||||
cityCode: data.cityCode,
|
|
||||||
address: data.address,
|
|
||||||
kycStatus: data.kycStatus,
|
|
||||||
realName: data.realName,
|
|
||||||
idCardNumber: data.idCardNumber,
|
|
||||||
idCardFrontUrl: data.idCardFrontUrl,
|
|
||||||
idCardBackUrl: data.idCardBackUrl,
|
|
||||||
status: data.status,
|
|
||||||
lastLoginAt: data.lastLoginAt,
|
|
||||||
updatedAt: data.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toDevicePersistence(
|
|
||||||
userId: string,
|
|
||||||
device: DeviceInfo,
|
|
||||||
): any {
|
|
||||||
return {
|
|
||||||
userId: BigInt(userId),
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
deviceName: device.deviceName,
|
|
||||||
addedAt: device.addedAt,
|
|
||||||
lastActiveAt: device.lastActiveAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toWalletPersistence(wallet: WalletAddress): any {
|
|
||||||
return {
|
|
||||||
userId: BigInt(wallet.userId),
|
|
||||||
chainType: wallet.chainType,
|
|
||||||
address: wallet.address,
|
|
||||||
encryptedMnemonic: wallet.encryptedMnemonic,
|
|
||||||
status: wallet.status,
|
|
||||||
boundAt: wallet.boundAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,13 @@
|
||||||
import {
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
OnModuleInit,
|
|
||||||
OnModuleDestroy,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
extends PrismaClient
|
async onModuleInit() {
|
||||||
implements OnModuleInit, OnModuleDestroy
|
|
||||||
{
|
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
log: [
|
|
||||||
{ emit: 'event', level: 'query' },
|
|
||||||
{ emit: 'stdout', level: 'info' },
|
|
||||||
{ emit: 'stdout', level: 'warn' },
|
|
||||||
{ emit: 'stdout', level: 'error' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
this.logger.log('Connected to database');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
this.logger.log('Disconnected from database');
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanDatabase(): Promise<void> {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
throw new Error('Cannot clean database in production');
|
|
||||||
}
|
|
||||||
|
|
||||||
const models = Reflect.ownKeys(this).filter(
|
|
||||||
(key) => typeof key === 'string' && !key.startsWith('_') && !key.startsWith('$'),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const model of models) {
|
|
||||||
try {
|
|
||||||
await (this as any)[model]?.deleteMany?.();
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors for non-model properties
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,239 +1,233 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { IUserAccountRepository } from '@domain/repositories/user-account.repository.interface';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccountRepository, Pagination } from '@/domain/repositories/user-account.repository.interface';
|
||||||
import { AccountSequence } from '@domain/value-objects/account-sequence.vo';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
import { PhoneNumber } from '@domain/value-objects/phone-number.vo';
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import { ReferralCode } from '@domain/value-objects/referral-code.vo';
|
import {
|
||||||
import { ChainType } from '@domain/enums/chain-type.enum';
|
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
|
||||||
import { WalletAddress } from '@domain/entities/wallet-address.entity';
|
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
} from '@/domain/value-objects';
|
||||||
import { UserAccountMapper } from '../mappers/user-account.mapper';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAccountRepositoryImpl implements IUserAccountRepository {
|
export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
private readonly logger = new Logger(UserAccountRepositoryImpl.name);
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly mapper: UserAccountMapper,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async save(account: UserAccount): Promise<void> {
|
async save(account: UserAccount): Promise<void> {
|
||||||
const data = this.mapper.toPersistence(account);
|
|
||||||
const devices = account.getAllDevices();
|
const devices = account.getAllDevices();
|
||||||
|
const wallets = account.getAllWalletAddresses();
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
// Upsert user account
|
|
||||||
await tx.userAccount.upsert({
|
await tx.userAccount.upsert({
|
||||||
where: { id: data.id },
|
where: { userId: BigInt(account.userId.value) },
|
||||||
create: {
|
create: {
|
||||||
...data,
|
userId: BigInt(account.userId.value),
|
||||||
registeredAt: new Date(),
|
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: {
|
update: {
|
||||||
phoneNumber: data.phoneNumber,
|
phoneNumber: account.phoneNumber?.value || null,
|
||||||
nickname: data.nickname,
|
nickname: account.nickname,
|
||||||
avatarUrl: data.avatarUrl,
|
avatarUrl: account.avatarUrl,
|
||||||
address: data.address,
|
provinceCode: account.province.value,
|
||||||
kycStatus: data.kycStatus,
|
cityCode: account.city.value,
|
||||||
realName: data.realName,
|
address: account.addressDetail,
|
||||||
idCardNumber: data.idCardNumber,
|
kycStatus: account.kycStatus,
|
||||||
idCardFrontUrl: data.idCardFrontUrl,
|
realName: account.kycInfo?.realName || null,
|
||||||
idCardBackUrl: data.idCardBackUrl,
|
idCardNumber: account.kycInfo?.idCardNumber || null,
|
||||||
status: data.status,
|
idCardFrontUrl: account.kycInfo?.idCardFrontUrl || null,
|
||||||
lastLoginAt: data.lastLoginAt,
|
idCardBackUrl: account.kycInfo?.idCardBackUrl || null,
|
||||||
updatedAt: data.updatedAt,
|
status: account.status,
|
||||||
|
lastLoginAt: account.lastLoginAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert devices
|
// Sync devices
|
||||||
for (const device of devices) {
|
await tx.userDevice.deleteMany({ where: { userId: BigInt(account.userId.value) } });
|
||||||
const deviceData = this.mapper.toDevicePersistence(account.userId, device);
|
if (devices.length > 0) {
|
||||||
await tx.userDevice.upsert({
|
await tx.userDevice.createMany({
|
||||||
where: {
|
data: devices.map((d) => ({
|
||||||
userId_deviceId: {
|
userId: BigInt(account.userId.value),
|
||||||
userId: deviceData.userId,
|
deviceId: d.deviceId,
|
||||||
deviceId: deviceData.deviceId,
|
deviceName: d.deviceName,
|
||||||
},
|
addedAt: d.addedAt,
|
||||||
},
|
lastActiveAt: d.lastActiveAt,
|
||||||
create: deviceData,
|
})),
|
||||||
update: {
|
|
||||||
deviceName: deviceData.deviceName,
|
|
||||||
lastActiveAt: deviceData.lastActiveAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug(`Saved user account: ${account.userId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveWallets(userId: string, wallets: WalletAddress[]): Promise<void> {
|
async saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void> {
|
||||||
await this.prisma.$transaction(
|
await this.prisma.walletAddress.createMany({
|
||||||
wallets.map((wallet) =>
|
data: wallets.map((w) => ({
|
||||||
this.prisma.walletAddress.create({
|
userId: BigInt(userId.value),
|
||||||
data: this.mapper.toWalletPersistence(wallet),
|
chainType: w.chainType,
|
||||||
}),
|
address: w.address,
|
||||||
),
|
encryptedMnemonic: w.encryptedMnemonic,
|
||||||
);
|
status: w.status,
|
||||||
|
boundAt: w.boundAt,
|
||||||
this.logger.debug(`Saved ${wallets.length} wallets for user: ${userId}`);
|
})),
|
||||||
}
|
skipDuplicates: true,
|
||||||
|
|
||||||
async findById(userId: string): Promise<UserAccount | null> {
|
|
||||||
const raw = await this.prisma.userAccount.findUnique({
|
|
||||||
where: { id: BigInt(userId) },
|
|
||||||
include: {
|
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return raw ? this.mapper.toDomain(raw) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByAccountSequence(
|
async findById(userId: UserId): Promise<UserAccount | null> {
|
||||||
sequence: AccountSequence,
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
): Promise<UserAccount | null> {
|
where: { userId: BigInt(userId.value) },
|
||||||
const raw = await this.prisma.userAccount.findUnique({
|
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) },
|
where: { accountSequence: BigInt(sequence.value) },
|
||||||
include: {
|
include: { devices: true, walletAddresses: true },
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return data ? this.toDomain(data) : null;
|
||||||
return raw ? this.mapper.toDomain(raw) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
|
async findByDeviceId(deviceId: string): Promise<UserAccount | null> {
|
||||||
const device = await this.prisma.userDevice.findFirst({
|
const device = await this.prisma.userDevice.findFirst({ where: { deviceId } });
|
||||||
where: { deviceId },
|
if (!device) return null;
|
||||||
include: {
|
return this.findById(UserId.create(device.userId.toString()));
|
||||||
user: {
|
|
||||||
include: {
|
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return device ? this.mapper.toDomain(device.user) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> {
|
async findByPhoneNumber(phoneNumber: PhoneNumber): Promise<UserAccount | null> {
|
||||||
const raw = await this.prisma.userAccount.findUnique({
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
where: { phoneNumber: phoneNumber.value },
|
where: { phoneNumber: phoneNumber.value },
|
||||||
include: {
|
include: { devices: true, walletAddresses: true },
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return data ? this.toDomain(data) : null;
|
||||||
return raw ? this.mapper.toDomain(raw) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByReferralCode(
|
async findByReferralCode(referralCode: ReferralCode): Promise<UserAccount | null> {
|
||||||
referralCode: ReferralCode,
|
const data = await this.prisma.userAccount.findUnique({
|
||||||
): Promise<UserAccount | null> {
|
|
||||||
const raw = await this.prisma.userAccount.findUnique({
|
|
||||||
where: { referralCode: referralCode.value },
|
where: { referralCode: referralCode.value },
|
||||||
include: {
|
include: { devices: true, walletAddresses: true },
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return data ? this.toDomain(data) : null;
|
||||||
return raw ? this.mapper.toDomain(raw) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByWalletAddress(
|
async findByWalletAddress(chainType: ChainType, address: string): Promise<UserAccount | null> {
|
||||||
chainType: ChainType,
|
|
||||||
address: string,
|
|
||||||
): Promise<UserAccount | null> {
|
|
||||||
const wallet = await this.prisma.walletAddress.findUnique({
|
const wallet = await this.prisma.walletAddress.findUnique({
|
||||||
where: {
|
where: { uk_chain_address: { chainType, address } },
|
||||||
chainType_address: {
|
|
||||||
chainType,
|
|
||||||
address,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
include: {
|
|
||||||
devices: true,
|
|
||||||
walletAddresses: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!wallet) return null;
|
||||||
|
return this.findById(UserId.create(wallet.userId.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
return wallet ? this.mapper.toDomain(wallet.user) : null;
|
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> {
|
async getNextAccountSequence(): Promise<AccountSequence> {
|
||||||
// 使用行级锁获取下一个序列号
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const result = await this.prisma.$queryRaw<{ current_sequence: bigint }[]>`
|
const updated = await tx.accountSequenceGenerator.update({
|
||||||
SELECT current_sequence FROM account_sequence_generator
|
where: { id: 1 },
|
||||||
WHERE id = 1
|
data: { currentSequence: { increment: 1 } },
|
||||||
FOR UPDATE
|
});
|
||||||
`;
|
return updated.currentSequence;
|
||||||
|
});
|
||||||
|
return AccountSequence.create(Number(result));
|
||||||
|
}
|
||||||
|
|
||||||
let nextSequence: number;
|
async findUsers(
|
||||||
|
filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string },
|
||||||
if (result.length === 0) {
|
pagination?: Pagination,
|
||||||
// 初始化序列号生成器
|
): Promise<UserAccount[]> {
|
||||||
await this.prisma.$executeRaw`
|
const where: any = {};
|
||||||
INSERT INTO account_sequence_generator (id, current_sequence)
|
if (filters?.status) where.status = filters.status;
|
||||||
VALUES (1, 1)
|
if (filters?.kycStatus) where.kycStatus = filters.kycStatus;
|
||||||
ON CONFLICT (id) DO UPDATE SET current_sequence = account_sequence_generator.current_sequence + 1
|
if (filters?.province) where.provinceCode = filters.province;
|
||||||
`;
|
if (filters?.city) where.cityCode = filters.city;
|
||||||
nextSequence = 1;
|
if (filters?.keyword) {
|
||||||
} else {
|
where.OR = [
|
||||||
// 更新并获取下一个序列号
|
{ nickname: { contains: filters.keyword } },
|
||||||
await this.prisma.$executeRaw`
|
{ phoneNumber: { contains: filters.keyword } },
|
||||||
UPDATE account_sequence_generator
|
];
|
||||||
SET current_sequence = current_sequence + 1, updated_at = NOW()
|
|
||||||
WHERE id = 1
|
|
||||||
`;
|
|
||||||
nextSequence = Number(result[0].current_sequence) + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AccountSequence.create(nextSequence);
|
const data = await this.prisma.userAccount.findMany({
|
||||||
}
|
where,
|
||||||
|
include: { devices: true, walletAddresses: true },
|
||||||
async existsByDeviceId(deviceId: string): Promise<boolean> {
|
skip: pagination ? (pagination.page - 1) * pagination.limit : undefined,
|
||||||
const count = await this.prisma.userDevice.count({
|
take: pagination?.limit,
|
||||||
where: { deviceId },
|
orderBy: { registeredAt: 'desc' },
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async existsByPhoneNumber(phoneNumber: PhoneNumber): Promise<boolean> {
|
|
||||||
const count = await this.prisma.userAccount.count({
|
|
||||||
where: { phoneNumber: phoneNumber.value },
|
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async existsByReferralCode(referralCode: ReferralCode): Promise<boolean> {
|
|
||||||
const count = await this.prisma.userAccount.count({
|
|
||||||
where: { referralCode: referralCode.value },
|
|
||||||
});
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeDevice(userId: string, deviceId: string): Promise<void> {
|
|
||||||
await this.prisma.userDevice.delete({
|
|
||||||
where: {
|
|
||||||
userId_deviceId: {
|
|
||||||
userId: BigInt(userId),
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug(`Removed device ${deviceId} from user ${userId}`);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,8 @@
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { RedisService } from './redis.service';
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
providers: [RedisService],
|
||||||
providers: [
|
exports: [RedisService],
|
||||||
{
|
|
||||||
provide: REDIS_CLIENT,
|
|
||||||
useFactory: (configService: ConfigService) => {
|
|
||||||
return new Redis({
|
|
||||||
host: configService.get('REDIS_HOST', 'localhost'),
|
|
||||||
port: configService.get('REDIS_PORT', 6379),
|
|
||||||
password: configService.get('REDIS_PASSWORD') || undefined,
|
|
||||||
db: configService.get('REDIS_DB', 0),
|
|
||||||
retryStrategy: (times) => {
|
|
||||||
const delay = Math.min(times * 50, 2000);
|
|
||||||
return delay;
|
|
||||||
},
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
inject: [ConfigService],
|
|
||||||
},
|
|
||||||
RedisService,
|
|
||||||
],
|
|
||||||
exports: [RedisService, REDIS_CLIENT],
|
|
||||||
})
|
})
|
||||||
export class RedisModule {}
|
export class RedisModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,32 @@
|
||||||
import { Injectable, Inject, Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { REDIS_CLIENT } from './redis.module';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisService implements OnModuleDestroy {
|
export class RedisService implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(RedisService.name);
|
private readonly client: Redis;
|
||||||
|
|
||||||
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.client.on('connect', () => {
|
this.client = new Redis({
|
||||||
this.logger.log('Redis connected');
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
});
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
|
||||||
this.client.on('error', (error) => {
|
db: this.configService.get<number>('REDIS_DB', 0),
|
||||||
this.logger.error('Redis error', error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async get(key: string): Promise<string | null> {
|
||||||
await this.client.quit();
|
return this.client.get(key);
|
||||||
this.logger.log('Redis disconnected');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||||
if (ttlSeconds) {
|
if (ttlSeconds) {
|
||||||
await this.client.setex(key, ttlSeconds, value);
|
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||||
} else {
|
} else {
|
||||||
await this.client.set(key, value);
|
await this.client.set(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(key: string): Promise<string | null> {
|
|
||||||
return await this.client.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
await this.client.del(key);
|
await this.client.del(key);
|
||||||
}
|
}
|
||||||
|
|
@ -42,94 +36,15 @@ export class RedisService implements OnModuleDestroy {
|
||||||
return result === 1;
|
return result === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setJSON<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
async incr(key: string): Promise<number> {
|
||||||
await this.set(key, JSON.stringify(value), ttlSeconds);
|
return this.client.incr(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJSON<T>(key: string): Promise<T | null> {
|
async expire(key: string, seconds: number): Promise<void> {
|
||||||
const value = await this.get(key);
|
await this.client.expire(key, seconds);
|
||||||
if (!value) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async increment(key: string): Promise<number> {
|
onModuleDestroy() {
|
||||||
return await this.client.incr(key);
|
this.client.disconnect();
|
||||||
}
|
|
||||||
|
|
||||||
async decrement(key: string): Promise<number> {
|
|
||||||
return await this.client.decr(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
|
||||||
await this.client.expire(key, ttlSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async ttl(key: string): Promise<number> {
|
|
||||||
return await this.client.ttl(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async keys(pattern: string): Promise<string[]> {
|
|
||||||
return await this.client.keys(pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByPattern(pattern: string): Promise<void> {
|
|
||||||
const keys = await this.keys(pattern);
|
|
||||||
if (keys.length > 0) {
|
|
||||||
await this.client.del(...keys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMS验证码相关方法
|
|
||||||
async setSmsCode(
|
|
||||||
phoneNumber: string,
|
|
||||||
type: string,
|
|
||||||
code: string,
|
|
||||||
ttlSeconds: number = 300,
|
|
||||||
): Promise<void> {
|
|
||||||
const key = `sms:${type}:${phoneNumber}`;
|
|
||||||
await this.set(key, code, ttlSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSmsCode(phoneNumber: string, type: string): Promise<string | null> {
|
|
||||||
const key = `sms:${type}:${phoneNumber}`;
|
|
||||||
return await this.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSmsCode(phoneNumber: string, type: string): Promise<void> {
|
|
||||||
const key = `sms:${type}:${phoneNumber}`;
|
|
||||||
await this.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token黑名单相关方法
|
|
||||||
async addToBlacklist(
|
|
||||||
tokenHash: string,
|
|
||||||
ttlSeconds: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const key = `blacklist:${tokenHash}`;
|
|
||||||
await this.set(key, '1', ttlSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isBlacklisted(tokenHash: string): Promise<boolean> {
|
|
||||||
const key = `blacklist:${tokenHash}`;
|
|
||||||
return await this.exists(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分布式锁
|
|
||||||
async acquireLock(
|
|
||||||
lockKey: string,
|
|
||||||
ttlSeconds: number = 30,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const key = `lock:${lockKey}`;
|
|
||||||
const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');
|
|
||||||
return result === 'OK';
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseLock(lockKey: string): Promise<void> {
|
|
||||||
const key = `lock:${lockKey}`;
|
|
||||||
await this.delete(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,45 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const logger = new Logger('Bootstrap');
|
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const configService = app.get(ConfigService);
|
|
||||||
|
|
||||||
// 全局前缀
|
// Global prefix
|
||||||
const apiPrefix = configService.get('API_PREFIX', 'api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
app.setGlobalPrefix(apiPrefix);
|
|
||||||
|
|
||||||
// CORS
|
// Validation
|
||||||
app.enableCors({
|
|
||||||
origin: true,
|
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全局验证管道
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
transform: true,
|
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
transformOptions: {
|
transform: true,
|
||||||
enableImplicitConversion: true,
|
transformOptions: { enableImplicitConversion: true },
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 全局异常过滤器
|
// CORS
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
app.enableCors({
|
||||||
|
origin: '*',
|
||||||
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Swagger文档
|
// Swagger
|
||||||
if (configService.get('NODE_ENV') !== 'production') {
|
const config = new DocumentBuilder()
|
||||||
const config = new DocumentBuilder()
|
.setTitle('Identity Service API')
|
||||||
.setTitle('Identity Service API')
|
.setDescription('RWA用户身份服务API')
|
||||||
.setDescription('RWA Identity & User Context Microservice')
|
.setVersion('2.0.0')
|
||||||
.setVersion('2.0.0')
|
.addBearerAuth()
|
||||||
.addBearerAuth()
|
.build();
|
||||||
.addTag('用户管理', '用户账户相关接口')
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
.build();
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const port = process.env.APP_PORT || 3000;
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
|
||||||
logger.log('Swagger documentation available at /api/docs');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接Kafka微服务
|
|
||||||
const kafkaBrokers = configService.get('KAFKA_BROKERS', 'localhost:9092');
|
|
||||||
try {
|
|
||||||
app.connectMicroservice<MicroserviceOptions>({
|
|
||||||
transport: Transport.KAFKA,
|
|
||||||
options: {
|
|
||||||
client: {
|
|
||||||
clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'),
|
|
||||||
brokers: kafkaBrokers.split(','),
|
|
||||||
},
|
|
||||||
consumer: {
|
|
||||||
groupId: configService.get('KAFKA_GROUP_ID', 'identity-service-group'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启动所有微服务
|
|
||||||
await app.startAllMicroservices();
|
|
||||||
logger.log('Kafka microservice connected');
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Kafka connection failed: ${error.message}. Service will continue without Kafka.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动HTTP服务
|
|
||||||
const port = configService.get('PORT', 3000);
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
console.log(`Identity Service is running on port ${port}`);
|
||||||
logger.log(`🚀 Identity Service is running on: http://localhost:${port}`);
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
logger.log(`📚 API Prefix: ${apiPrefix}`);
|
|
||||||
logger.log(`🌍 Environment: ${configService.get('NODE_ENV', 'development')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
export class ApplicationException extends Error {
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
export class ApplicationException extends HttpException {
|
||||||
this.name = 'ApplicationException';
|
constructor(
|
||||||
Object.setPrototypeOf(this, ApplicationException.prototype);
|
message: string,
|
||||||
|
public readonly code?: string,
|
||||||
|
status: HttpStatus = HttpStatus.BAD_REQUEST,
|
||||||
|
) {
|
||||||
|
super({ message, code, success: false }, status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,40 @@
|
||||||
export class DomainException extends Error {
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class DomainError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'DomainException';
|
this.name = 'DomainError';
|
||||||
Object.setPrototypeOf(this, DomainException.prototype);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,67 +1,65 @@
|
||||||
import {
|
import {
|
||||||
ExceptionFilter,
|
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
|
||||||
Catch,
|
Injectable, NestInterceptor, ExecutionContext, CallHandler,
|
||||||
ArgumentsHost,
|
|
||||||
HttpStatus,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { DomainException } from '../exceptions/domain.exception';
|
import { Observable } from 'rxjs';
|
||||||
import { ApplicationException } from '../exceptions/application.exception';
|
import { map } from 'rxjs/operators';
|
||||||
|
import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest();
|
|
||||||
|
|
||||||
let status: number;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let message: string;
|
let message = '服务器内部错误';
|
||||||
let error: string;
|
let code: string | undefined;
|
||||||
|
|
||||||
if (exception instanceof DomainException) {
|
if (exception instanceof HttpException) {
|
||||||
status = HttpStatus.BAD_REQUEST;
|
|
||||||
message = exception.message;
|
|
||||||
error = 'DomainException';
|
|
||||||
} else if (exception instanceof ApplicationException) {
|
|
||||||
status = HttpStatus.BAD_REQUEST;
|
|
||||||
message = exception.message;
|
|
||||||
error = 'ApplicationException';
|
|
||||||
} else if (exception instanceof HttpException) {
|
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const exceptionResponse = exception.getResponse();
|
const exceptionResponse = exception.getResponse();
|
||||||
message =
|
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||||
typeof exceptionResponse === 'string'
|
message = (exceptionResponse as any).message || message;
|
||||||
? exceptionResponse
|
code = (exceptionResponse as any).code;
|
||||||
: (exceptionResponse as any).message || exception.message;
|
} else {
|
||||||
error = exception.name;
|
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) {
|
} else if (exception instanceof Error) {
|
||||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
message = exception.message;
|
||||||
message = '服务器内部错误';
|
|
||||||
error = 'InternalServerError';
|
|
||||||
|
|
||||||
// 记录未知错误
|
|
||||||
this.logger.error(
|
|
||||||
`Unhandled exception: ${exception.message}`,
|
|
||||||
exception.stack,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
||||||
message = '未知错误';
|
|
||||||
error = 'UnknownError';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
statusCode: status,
|
code,
|
||||||
message,
|
message,
|
||||||
error,
|
|
||||||
path: request.url,
|
|
||||||
timestamp: new Date().toISOString(),
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,68 @@
|
||||||
import {
|
import { Injectable, CanActivate, ExecutionContext, createParamDecorator, SetMetadata } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
ExecutionContext,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
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 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()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(
|
||||||
super();
|
private readonly jwtService: JwtService,
|
||||||
}
|
private readonly reflector: Reflector,
|
||||||
|
) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
if (isPublic) {
|
const request = context.switchToHttp().getRequest();
|
||||||
return true;
|
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 super.canActivate(context);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRequest(err: any, user: any, info: any) {
|
private extractTokenFromHeader(request: any): string | undefined {
|
||||||
if (err || !user) {
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
throw err || new UnauthorizedException('未授权访问');
|
return type === 'Bearer' ? token : undefined;
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,20 +13,12 @@
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": true,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": true,
|
||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": false,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/*"],
|
"@/*": ["src/*"]
|
||||||
"@domain/*": ["src/domain/*"],
|
|
||||||
"@application/*": ["src/application/*"],
|
|
||||||
"@infrastructure/*": ["src/infrastructure/*"],
|
|
||||||
"@api/*": ["src/api/*"],
|
|
||||||
"@shared/*": ["src/shared/*"],
|
|
||||||
"@config/*": ["src/config/*"]
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "test"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue