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