From c29c185a038a3eb3ed12faa80ceffcf83199d7c0 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 24 Nov 2025 06:09:06 +0000 Subject: [PATCH] identity_service_context first commit --- .../services/identity-service/.env.example | 38 ++ .../services/identity-service/.eslintrc.js | 26 + backend/services/identity-service/.gitignore | 43 ++ backend/services/identity-service/.prettierrc | 7 + backend/services/identity-service/Dockerfile | 50 ++ backend/services/identity-service/README.md | 181 +++++++ .../identity-service/docker-compose.yml | 125 +++++ .../services/identity-service/nest-cli.json | 8 + .../services/identity-service/package.json | 108 ++++ .../identity-service/prisma/schema.prisma | 176 +++++++ .../services/identity-service/prisma/seed.ts | 27 + .../controllers/user-account.controller.ts | 314 ++++++++++++ .../src/api/dto/request/index.ts | 178 +++++++ .../src/api/dto/response/index.ts | 162 ++++++ .../identity-service/src/app.module.ts | 92 ++++ .../auto-create-account.command.ts | 9 + .../auto-create-account.handler.ts | 147 ++++++ .../recover-by-mnemonic.command.ts | 8 + .../recover-by-mnemonic.handler.ts | 120 +++++ .../recover-by-phone.command.ts | 9 + .../recover-by-phone.handler.ts | 110 ++++ .../src/application/services/token.service.ts | 234 +++++++++ .../domain/aggregates/user-account/index.ts | 1 + .../user-account/user-account.aggregate.ts | 470 ++++++++++++++++++ .../src/domain/entities/index.ts | 1 + .../domain/entities/wallet-address.entity.ts | 90 ++++ .../src/domain/enums/account-status.enum.ts | 5 + .../src/domain/enums/chain-type.enum.ts | 20 + .../src/domain/enums/index.ts | 3 + .../src/domain/enums/kyc-status.enum.ts | 6 + .../src/domain/events/device-added.event.ts | 33 ++ .../src/domain/events/domain-event.base.ts | 15 + .../src/domain/events/index.ts | 5 + .../src/domain/events/kyc-submitted.event.ts | 31 ++ .../domain/events/phone-number-bound.event.ts | 29 ++ .../events/user-account-created.event.ts | 39 ++ .../src/domain/repositories/index.ts | 1 + .../user-account.repository.interface.ts | 78 +++ .../src/domain/services/index.ts | 1 + .../domain/services/user-validator.service.ts | 80 +++ .../value-objects/account-sequence.vo.ts | 33 ++ .../domain/value-objects/device-info.vo.ts | 38 ++ .../src/domain/value-objects/index.ts | 6 + .../src/domain/value-objects/kyc-info.vo.ts | 74 +++ .../src/domain/value-objects/mnemonic.vo.ts | 49 ++ .../domain/value-objects/phone-number.vo.ts | 34 ++ .../domain/value-objects/referral-code.vo.ts | 39 ++ .../blockchain/wallet-generator.service.ts | 212 ++++++++ .../external/sms/sms.service.ts | 150 ++++++ .../kafka/dead-letter.service.ts | 83 ++++ .../kafka/event-consumer.controller.ts | 237 +++++++++ .../kafka/event-publisher.service.ts | 241 +++++++++ .../kafka/event-retry.service.ts | 91 ++++ .../src/infrastructure/kafka/index.ts | 5 + .../src/infrastructure/kafka/kafka.module.ts | 46 ++ .../mappers/user-account.mapper.ts | 142 ++++++ .../persistence/prisma/prisma.service.ts | 54 ++ .../user-account.repository.impl.ts | 239 +++++++++ .../src/infrastructure/redis/redis.module.ts | 33 ++ .../src/infrastructure/redis/redis.service.ts | 135 +++++ backend/services/identity-service/src/main.ts | 88 ++++ .../src/shared/decorators/index.ts | 18 + .../exceptions/application.exception.ts | 7 + .../src/shared/exceptions/domain.exception.ts | 7 + .../src/shared/exceptions/index.ts | 2 + .../shared/filters/global-exception.filter.ts | 67 +++ .../src/shared/guards/jwt-auth.guard.ts | 36 ++ .../src/shared/strategies/jwt.strategy.ts | 36 ++ .../identity-service/test/jest-e2e.json | 17 + .../identity-service/tsconfig.build.json | 4 + .../services/identity-service/tsconfig.json | 32 ++ 71 files changed, 5335 insertions(+) create mode 100644 backend/services/identity-service/.env.example create mode 100644 backend/services/identity-service/.eslintrc.js create mode 100644 backend/services/identity-service/.gitignore create mode 100644 backend/services/identity-service/.prettierrc create mode 100644 backend/services/identity-service/README.md create mode 100644 backend/services/identity-service/docker-compose.yml create mode 100644 backend/services/identity-service/nest-cli.json create mode 100644 backend/services/identity-service/prisma/schema.prisma create mode 100644 backend/services/identity-service/prisma/seed.ts create mode 100644 backend/services/identity-service/src/api/controllers/user-account.controller.ts create mode 100644 backend/services/identity-service/src/api/dto/request/index.ts create mode 100644 backend/services/identity-service/src/api/dto/response/index.ts create mode 100644 backend/services/identity-service/src/app.module.ts create mode 100644 backend/services/identity-service/src/application/commands/auto-create-account/auto-create-account.command.ts create mode 100644 backend/services/identity-service/src/application/commands/auto-create-account/auto-create-account.handler.ts create mode 100644 backend/services/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.command.ts create mode 100644 backend/services/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.handler.ts create mode 100644 backend/services/identity-service/src/application/commands/recover-by-phone/recover-by-phone.command.ts create mode 100644 backend/services/identity-service/src/application/commands/recover-by-phone/recover-by-phone.handler.ts create mode 100644 backend/services/identity-service/src/application/services/token.service.ts create mode 100644 backend/services/identity-service/src/domain/aggregates/user-account/index.ts create mode 100644 backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts create mode 100644 backend/services/identity-service/src/domain/entities/index.ts create mode 100644 backend/services/identity-service/src/domain/entities/wallet-address.entity.ts create mode 100644 backend/services/identity-service/src/domain/enums/account-status.enum.ts create mode 100644 backend/services/identity-service/src/domain/enums/chain-type.enum.ts create mode 100644 backend/services/identity-service/src/domain/enums/index.ts create mode 100644 backend/services/identity-service/src/domain/enums/kyc-status.enum.ts create mode 100644 backend/services/identity-service/src/domain/events/device-added.event.ts create mode 100644 backend/services/identity-service/src/domain/events/domain-event.base.ts create mode 100644 backend/services/identity-service/src/domain/events/index.ts create mode 100644 backend/services/identity-service/src/domain/events/kyc-submitted.event.ts create mode 100644 backend/services/identity-service/src/domain/events/phone-number-bound.event.ts create mode 100644 backend/services/identity-service/src/domain/events/user-account-created.event.ts create mode 100644 backend/services/identity-service/src/domain/repositories/index.ts create mode 100644 backend/services/identity-service/src/domain/repositories/user-account.repository.interface.ts create mode 100644 backend/services/identity-service/src/domain/services/index.ts create mode 100644 backend/services/identity-service/src/domain/services/user-validator.service.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/account-sequence.vo.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/device-info.vo.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/index.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/kyc-info.vo.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/mnemonic.vo.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/phone-number.vo.ts create mode 100644 backend/services/identity-service/src/domain/value-objects/referral-code.vo.ts create mode 100644 backend/services/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/dead-letter.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/event-consumer.controller.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/event-publisher.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/event-retry.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/index.ts create mode 100644 backend/services/identity-service/src/infrastructure/kafka/kafka.module.ts create mode 100644 backend/services/identity-service/src/infrastructure/persistence/mappers/user-account.mapper.ts create mode 100644 backend/services/identity-service/src/infrastructure/persistence/prisma/prisma.service.ts create mode 100644 backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts create mode 100644 backend/services/identity-service/src/infrastructure/redis/redis.module.ts create mode 100644 backend/services/identity-service/src/infrastructure/redis/redis.service.ts create mode 100644 backend/services/identity-service/src/main.ts create mode 100644 backend/services/identity-service/src/shared/decorators/index.ts create mode 100644 backend/services/identity-service/src/shared/exceptions/application.exception.ts create mode 100644 backend/services/identity-service/src/shared/exceptions/domain.exception.ts create mode 100644 backend/services/identity-service/src/shared/exceptions/index.ts create mode 100644 backend/services/identity-service/src/shared/filters/global-exception.filter.ts create mode 100644 backend/services/identity-service/src/shared/guards/jwt-auth.guard.ts create mode 100644 backend/services/identity-service/src/shared/strategies/jwt.strategy.ts create mode 100644 backend/services/identity-service/test/jest-e2e.json create mode 100644 backend/services/identity-service/tsconfig.build.json diff --git a/backend/services/identity-service/.env.example b/backend/services/identity-service/.env.example new file mode 100644 index 00000000..685a7a78 --- /dev/null +++ b/backend/services/identity-service/.env.example @@ -0,0 +1,38 @@ +# Application +NODE_ENV=development +PORT=3000 +API_PREFIX=api/v1 + +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/identity_db?schema=public" + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# JWT +JWT_SECRET=your-super-secret-key-change-in-production +JWT_ACCESS_EXPIRATION=2h +JWT_REFRESH_EXPIRATION=30d + +# Kafka +KAFKA_BROKERS=localhost:9092 +KAFKA_CLIENT_ID=identity-service +KAFKA_GROUP_ID=identity-service-group + +# SMS Provider (Aliyun) +SMS_PROVIDER=aliyun +SMS_ACCESS_KEY_ID=your-access-key-id +SMS_ACCESS_KEY_SECRET=your-access-key-secret +SMS_SIGN_NAME=RWA平台 +SMS_TEMPLATE_CODE=SMS_123456789 + +# Blockchain RPC +KAVA_RPC_URL=https://kava-rpc.example.com +DST_RPC_URL=https://dst-rpc.example.com +BSC_RPC_URL=https://bsc-dataseed.binance.org + +# Wallet Encryption +WALLET_ENCRYPTION_SALT=rwa-wallet-salt-change-in-production diff --git a/backend/services/identity-service/.eslintrc.js b/backend/services/identity-service/.eslintrc.js new file mode 100644 index 00000000..598e3603 --- /dev/null +++ b/backend/services/identity-service/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/backend/services/identity-service/.gitignore b/backend/services/identity-service/.gitignore new file mode 100644 index 00000000..e0719925 --- /dev/null +++ b/backend/services/identity-service/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +dist/ +build/ + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +coverage/ +.nyc_output + +# Prisma +prisma/migrations/**/migration_lock.toml + +# Misc +*.tgz +.cache diff --git a/backend/services/identity-service/.prettierrc b/backend/services/identity-service/.prettierrc new file mode 100644 index 00000000..f721565c --- /dev/null +++ b/backend/services/identity-service/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "printWidth": 80 +} diff --git a/backend/services/identity-service/Dockerfile b/backend/services/identity-service/Dockerfile index e69de29b..9add21e2 100644 --- a/backend/services/identity-service/Dockerfile +++ b/backend/services/identity-service/Dockerfile @@ -0,0 +1,50 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Copy source +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Copy built assets +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package*.json ./ + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 -G nodejs + +# Set ownership +RUN chown -R nestjs:nodejs /app + +USER nestjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/v1/health || exit 1 + +# Start +CMD ["npm", "run", "start:prod"] diff --git a/backend/services/identity-service/README.md b/backend/services/identity-service/README.md new file mode 100644 index 00000000..df08d9dc --- /dev/null +++ b/backend/services/identity-service/README.md @@ -0,0 +1,181 @@ +# Identity Service + +RWA平台用户身份管理微服务 - 基于NestJS + Prisma + Clean Architecture + +## 技术栈 + +- **框架**: NestJS 10 +- **ORM**: Prisma 5 +- **数据库**: PostgreSQL 15 +- **缓存**: Redis 7 (ioredis) +- **消息队列**: Kafka +- **区块链**: ethers.js 6 +- **架构**: Clean Architecture / Hexagonal Architecture / DDD / CQRS + +## 功能特性 + +- ✅ 自动创建账户(首次打开APP) +- ✅ 多设备支持(最多5个设备同时登录) +- ✅ 助记词生成与恢复 +- ✅ 手机号绑定与恢复 +- ✅ 三链钱包地址派生(KAVA/DST/BSC) +- ✅ JWT Token认证 +- ✅ Token自动刷新(账户永不过期) +- ✅ Kafka事件发布 +- ✅ 死信队列与自动重试 + +## 目录结构 + +``` +src/ +├── api/ # 表现层 +│ ├── controllers/ # HTTP控制器 +│ └── dto/ # 数据传输对象 +├── application/ # 应用层 +│ ├── commands/ # 命令处理器 +│ ├── queries/ # 查询处理器 +│ └── services/ # 应用服务 +├── domain/ # 领域层 +│ ├── aggregates/ # 聚合根 +│ ├── entities/ # 实体 +│ ├── value-objects/ # 值对象 +│ ├── events/ # 领域事件 +│ ├── repositories/ # 仓储接口 +│ └── services/ # 领域服务 +├── infrastructure/ # 基础设施层 +│ ├── persistence/ # 数据持久化 +│ ├── redis/ # Redis缓存 +│ ├── kafka/ # Kafka消息 +│ └── external/ # 外部服务 +└── shared/ # 共享层 + ├── decorators/ # 装饰器 + ├── guards/ # 守卫 + ├── filters/ # 过滤器 + └── exceptions/ # 异常 +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +npm install +``` + +### 2. 配置环境变量 + +```bash +cp .env.example .env +# 编辑.env文件配置数据库连接等 +``` + +### 3. 数据库迁移 + +```bash +# 生成Prisma客户端 +npm run prisma:generate + +# 运行迁移 +npm run prisma:migrate +``` + +### 4. 启动服务 + +```bash +# 开发模式 +npm run start:dev + +# 生产模式 +npm run build +npm run start:prod +``` + +## Docker部署 + +### 启动所有服务 + +```bash +docker-compose up -d +``` + +### 仅启动依赖服务 + +```bash +docker-compose up -d postgres redis kafka zookeeper +``` + +## API文档 + +启动服务后访问: http://localhost:3000/api/docs + +## 主要API接口 + +| 方法 | 路径 | 描述 | 认证 | +|------|------|------|------| +| POST | /api/v1/user/auto-create | 自动创建账户 | 否 | +| POST | /api/v1/user/recover-by-mnemonic | 助记词恢复 | 否 | +| POST | /api/v1/user/recover-by-phone | 手机号恢复 | 否 | +| POST | /api/v1/user/refresh-token | 刷新Token | 否 | +| POST | /api/v1/user/send-sms-code | 发送验证码 | 否 | +| POST | /api/v1/user/bind-phone | 绑定手机号 | 是 | +| GET | /api/v1/user/my-profile | 我的资料 | 是 | +| GET | /api/v1/user/my-devices | 我的设备 | 是 | +| DELETE | /api/v1/user/remove-device | 移除设备 | 是 | +| POST | /api/v1/user/logout | 退出登录 | 是 | + +## Kafka Topics + +| Topic | 描述 | +|-------|------| +| identity.user-account.created | 用户账户创建 | +| identity.device.added | 设备添加 | +| identity.device.removed | 设备移除 | +| identity.phone.bound | 手机号绑定 | +| identity.kyc.submitted | KYC提交 | +| identity.kyc.approved | KYC通过 | +| identity.kyc.rejected | KYC拒绝 | +| identity.account.frozen | 账户冻结 | +| identity.wallet.bound | 钱包绑定 | + +## 测试 + +```bash +# 单元测试 +npm run test + +# E2E测试 +npm run test:e2e + +# 测试覆盖率 +npm run test:cov +``` + +## 开发命令 + +```bash +# 格式化代码 +npm run format + +# Lint检查 +npm run lint + +# 打开Prisma Studio +npm run prisma:studio +``` + +## 环境变量 + +| 变量 | 描述 | 默认值 | +|------|------|--------| +| PORT | 服务端口 | 3000 | +| DATABASE_URL | 数据库连接 | - | +| REDIS_HOST | Redis主机 | localhost | +| REDIS_PORT | Redis端口 | 6379 | +| JWT_SECRET | JWT密钥 | - | +| JWT_ACCESS_EXPIRATION | AccessToken有效期 | 2h | +| JWT_REFRESH_EXPIRATION | RefreshToken有效期 | 30d | +| KAFKA_BROKERS | Kafka地址 | localhost:9092 | + +## License + +MIT diff --git a/backend/services/identity-service/docker-compose.yml b/backend/services/identity-service/docker-compose.yml new file mode 100644 index 00000000..5aec9ead --- /dev/null +++ b/backend/services/identity-service/docker-compose.yml @@ -0,0 +1,125 @@ +version: '3.8' + +services: + identity-service: + build: + context: . + dockerfile: Dockerfile + container_name: identity-service + ports: + - '3000:3000' + environment: + - NODE_ENV=production + - PORT=3000 + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/identity_db?schema=public + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-key-change-in-production} + - JWT_ACCESS_EXPIRATION=2h + - JWT_REFRESH_EXPIRATION=30d + - KAFKA_BROKERS=kafka:29092 + - KAFKA_CLIENT_ID=identity-service + - KAFKA_GROUP_ID=identity-service-group + - WALLET_ENCRYPTION_SALT=${WALLET_ENCRYPTION_SALT:-rwa-wallet-salt} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + kafka: + condition: service_started + networks: + - rwa-network + restart: unless-stopped + + postgres: + image: postgres:15-alpine + container_name: identity-postgres + ports: + - '5432:5432' + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=identity_db + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - rwa-network + + redis: + image: redis:7-alpine + container_name: identity-redis + ports: + - '6379:6379' + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - rwa-network + + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + container_name: identity-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + volumes: + - zookeeper-data:/var/lib/zookeeper/data + - zookeeper-logs:/var/lib/zookeeper/log + networks: + - rwa-network + + kafka: + image: confluentinc/cp-kafka:7.5.0 + container_name: identity-kafka + depends_on: + - zookeeper + ports: + - '9092:9092' + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' + KAFKA_LOG_RETENTION_HOURS: 168 + volumes: + - kafka-data:/var/lib/kafka/data + networks: + - rwa-network + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: identity-kafka-ui + ports: + - '8080:8080' + environment: + KAFKA_CLUSTERS_0_NAME: identity-cluster + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092 + KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181 + depends_on: + - kafka + networks: + - rwa-network + +volumes: + postgres-data: + redis-data: + zookeeper-data: + zookeeper-logs: + kafka-data: + +networks: + rwa-network: + driver: bridge diff --git a/backend/services/identity-service/nest-cli.json b/backend/services/identity-service/nest-cli.json new file mode 100644 index 00000000..f9aa683b --- /dev/null +++ b/backend/services/identity-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/services/identity-service/package.json b/backend/services/identity-service/package.json index e69de29b..04f6503f 100644 --- a/backend/services/identity-service/package.json +++ b/backend/services/identity-service/package.json @@ -0,0 +1,108 @@ +{ + "name": "identity-service", + "version": "2.0.0", + "description": "Identity & User Context Microservice - RWA Platform", + "author": "RWA Team", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:prod": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "prisma:seed": "prisma db seed" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/cqrs": "^10.2.6", + "@nestjs/jwt": "^10.2.0", + "@nestjs/microservices": "^10.3.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/schedule": "^4.0.0", + "@nestjs/swagger": "^7.1.17", + "@prisma/client": "^5.8.0", + "@scure/bip32": "^1.3.2", + "bech32": "^2.0.0", + "bip39": "^3.1.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "ethers": "^6.9.0", + "ioredis": "^5.3.2", + "kafkajs": "^2.2.4", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/node": "^20.10.6", + "@types/passport-jwt": "^4.0.0", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.18.0", + "@typescript-eslint/parser": "^6.18.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.2", + "jest": "^29.7.0", + "prettier": "^3.1.1", + "prisma": "^5.8.0", + "rimraf": "^5.0.5", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^@app/(.*)$": "/$1", + "^@domain/(.*)$": "/domain/$1", + "^@application/(.*)$": "/application/$1", + "^@infrastructure/(.*)$": "/infrastructure/$1", + "^@api/(.*)$": "/api/$1", + "^@shared/(.*)$": "/shared/$1", + "^@config/(.*)$": "/config/$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 new file mode 100644 index 00000000..83f61abd --- /dev/null +++ b/backend/services/identity-service/prisma/schema.prisma @@ -0,0 +1,176 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// 用户账户表 +model UserAccount { + id BigInt @id @default(autoincrement()) @map("user_id") + accountSequence BigInt @unique @map("account_sequence") + + // 基本信息 + phoneNumber String? @unique @map("phone_number") @db.VarChar(20) + nickname String @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.VarChar(500) + + // 推荐信息 + inviterSequence BigInt? @map("inviter_sequence") + referralCode String @unique @map("referral_code") @db.VarChar(10) + + // 区域信息 + provinceCode String @map("province_code") @db.VarChar(10) + cityCode String @map("city_code") @db.VarChar(10) + address String? @db.VarChar(500) + + // KYC信息 + kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) + realName String? @map("real_name") @db.VarChar(100) + idCardNumber String? @map("id_card_number") @db.VarChar(20) + idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500) + idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500) + kycVerifiedAt DateTime? @map("kyc_verified_at") + + // 账户状态 + status String @default("ACTIVE") @db.VarChar(20) + + // 时间戳 + registeredAt DateTime @default(now()) @map("registered_at") + lastLoginAt DateTime? @map("last_login_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + devices UserDevice[] + walletAddresses WalletAddress[] + events UserEvent[] + + @@index([phoneNumber]) + @@index([accountSequence]) + @@index([referralCode]) + @@index([inviterSequence]) + @@index([provinceCode, cityCode]) + @@index([kycStatus]) + @@index([status]) + @@map("user_accounts") +} + +// 用户设备表 +model UserDevice { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + deviceName String? @map("device_name") @db.VarChar(100) + + addedAt DateTime @default(now()) @map("added_at") + lastActiveAt DateTime @default(now()) @map("last_active_at") + + user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, deviceId]) + @@index([deviceId]) + @@index([userId]) + @@index([lastActiveAt]) + @@map("user_devices") +} + +// 区块链钱包地址表 +model WalletAddress { + id BigInt @id @default(autoincrement()) @map("address_id") + userId BigInt @map("user_id") + + chainType String @map("chain_type") @db.VarChar(20) + address String @db.VarChar(100) + encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text + + status String @default("ACTIVE") @db.VarChar(20) + boundAt DateTime @default(now()) @map("bound_at") + + user UserAccount @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, chainType]) + @@unique([chainType, address]) + @@index([userId]) + @@index([address]) + @@map("wallet_addresses") +} + +// 设备Token表 +model DeviceToken { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64) + + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + @@index([userId, deviceId]) + @@index([expiresAt]) + @@map("device_tokens") +} + +// 用户事件表 +model UserEvent { + id BigInt @id @default(autoincrement()) @map("event_id") + eventType String @map("event_type") @db.VarChar(50) + + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + + eventData Json @map("event_data") + + userId BigInt? @map("user_id") + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(6) + version Int @default(1) + + user UserAccount? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([aggregateType, aggregateId]) + @@index([eventType]) + @@index([userId]) + @@index([occurredAt]) + @@map("user_events") +} + +// 短信验证码表 +model SmsCode { + id BigInt @id @default(autoincrement()) + phoneNumber String @map("phone_number") @db.VarChar(20) + code String @db.VarChar(6) + type String @db.VarChar(20) // REGISTER, LOGIN, BIND, RECOVER + + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([phoneNumber, type]) + @@index([expiresAt]) + @@map("sms_codes") +} + +// 死信队列表 +model DeadLetterEvent { + id BigInt @id @default(autoincrement()) + topic String @db.VarChar(100) + eventId String @map("event_id") @db.VarChar(50) + eventType String @map("event_type") @db.VarChar(50) + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + payload Json + errorMessage String @map("error_message") @db.Text + errorStack String? @map("error_stack") @db.Text + retryCount Int @default(0) @map("retry_count") + createdAt DateTime @default(now()) @map("created_at") + processedAt DateTime? @map("processed_at") + + @@index([topic]) + @@index([eventType]) + @@index([createdAt]) + @@index([processedAt]) + @@map("dead_letter_events") +} diff --git a/backend/services/identity-service/prisma/seed.ts b/backend/services/identity-service/prisma/seed.ts new file mode 100644 index 00000000..b0707799 --- /dev/null +++ b/backend/services/identity-service/prisma/seed.ts @@ -0,0 +1,27 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Seeding database...'); + + // 清理现有数据 + await prisma.deadLetterEvent.deleteMany(); + await prisma.smsCode.deleteMany(); + await prisma.userEvent.deleteMany(); + await prisma.deviceToken.deleteMany(); + await prisma.walletAddress.deleteMany(); + await prisma.userDevice.deleteMany(); + await prisma.userAccount.deleteMany(); + + console.log('Database seeded successfully!'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); 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 new file mode 100644 index 00000000..b6b3423f --- /dev/null +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -0,0 +1,314 @@ +import { + Controller, + Post, + Get, + Delete, + Body, + UseGuards, + HttpCode, + HttpStatus, + Inject, + Logger, +} from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { + AutoCreateAccountDto, + RecoverByMnemonicDto, + RecoverByPhoneDto, + RefreshTokenDto, + SendSmsCodeDto, + BindPhoneDto, + RemoveDeviceDto, +} from '../dto/request'; +import { AutoCreateAccountCommand } from '@application/commands/auto-create-account/auto-create-account.command'; +import { RecoverByMnemonicCommand } from '@application/commands/recover-by-mnemonic/recover-by-mnemonic.command'; +import { RecoverByPhoneCommand } from '@application/commands/recover-by-phone/recover-by-phone.command'; +import { TokenService } from '@application/services/token.service'; +import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service'; +import { + IUserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '@domain/repositories/user-account.repository.interface'; +import { PhoneNumber } from '@domain/value-objects/phone-number.vo'; +import { JwtAuthGuard } from '@shared/guards/jwt-auth.guard'; +import { CurrentUser, CurrentDeviceId, Public } from '@shared/decorators'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +@ApiTags('用户管理') +@Controller('user') +export class UserAccountController { + private readonly logger = new Logger(UserAccountController.name); + + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly tokenService: TokenService, + private readonly smsService: SmsService, + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: IUserAccountRepository, + ) {} + + @Post('auto-create') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '自动创建账户' }) + @ApiResponse({ status: 200, description: '创建成功' }) + async autoCreate(@Body() dto: AutoCreateAccountDto) { + const command = new AutoCreateAccountCommand( + dto.deviceId, + dto.deviceName, + dto.inviterReferralCode, + dto.provinceCode, + dto.cityCode, + ); + + const result = await this.commandBus.execute(command); + + return { + success: true, + message: '账户创建成功', + data: result, + }; + } + + @Post('recover-by-mnemonic') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '用助记词恢复账户' }) + @ApiResponse({ status: 200, description: '恢复成功' }) + async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) { + const command = new RecoverByMnemonicCommand( + dto.accountSequence, + dto.mnemonic, + dto.newDeviceId, + dto.deviceName, + ); + + const result = await this.commandBus.execute(command); + + return { + success: true, + message: '账户恢复成功', + data: result, + }; + } + + @Post('recover-by-phone') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '用手机号恢复账户' }) + @ApiResponse({ status: 200, description: '恢复成功' }) + async recoverByPhone(@Body() dto: RecoverByPhoneDto) { + const command = new RecoverByPhoneCommand( + dto.accountSequence, + dto.phoneNumber, + dto.smsCode, + dto.newDeviceId, + dto.deviceName, + ); + + const result = await this.commandBus.execute(command); + + return { + success: true, + message: '账户恢复成功', + data: result, + }; + } + + @Post('refresh-token') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '刷新Token' }) + @ApiResponse({ status: 200, description: '刷新成功' }) + async refreshToken(@Body() dto: RefreshTokenDto) { + const payload = await this.tokenService.verifyRefreshToken(dto.refreshToken); + + // 验证设备 + const account = await this.userRepository.findById(payload.userId); + if (!account) { + throw new ApplicationException('账户不存在'); + } + + if (!account.isDeviceAuthorized(dto.deviceId)) { + throw new ApplicationException('设备未授权'); + } + + // 更新设备活跃时间 + account.updateDeviceActivity(dto.deviceId); + await this.userRepository.save(account); + + // 生成新Token + const tokens = await this.tokenService.refreshTokens(dto.refreshToken); + + return { + success: true, + message: 'Token刷新成功', + data: tokens, + }; + } + + @Post('send-sms-code') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '发送短信验证码' }) + @ApiResponse({ status: 200, description: '发送成功' }) + async sendSmsCode(@Body() dto: SendSmsCodeDto) { + const result = await this.smsService.sendVerificationCode( + dto.phoneNumber, + dto.type as SmsType, + ); + + return { + success: true, + message: result.message, + }; + } + + @Post('bind-phone') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '绑定手机号' }) + @ApiResponse({ status: 200, description: '绑定成功' }) + async bindPhone(@Body() dto: BindPhoneDto, @CurrentUser() user: any) { + // 验证短信验证码 + await this.smsService.verifyCode(dto.phoneNumber, SmsType.BIND, dto.smsCode); + + // 获取账户 + const account = await this.userRepository.findById(user.userId); + if (!account) { + throw new ApplicationException('账户不存在'); + } + + // 绑定手机号 + const phoneNumber = PhoneNumber.create(dto.phoneNumber); + account.bindPhoneNumber(phoneNumber); + + await this.userRepository.save(account); + + return { + success: true, + message: '手机号绑定成功', + }; + } + + @Get('my-profile') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '查询我的资料' }) + @ApiResponse({ status: 200, description: '查询成功' }) + async getMyProfile(@CurrentUser() user: any) { + const account = await this.userRepository.findById(user.userId); + if (!account) { + throw new ApplicationException('账户不存在'); + } + + return { + success: true, + data: { + userId: account.userId, + accountSequence: account.accountSequence.value, + phoneNumber: account.phoneNumber?.masked() || null, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + provinceCode: account.provinceCode, + cityCode: account.cityCode, + address: account.address, + walletAddresses: account.getAllWalletAddresses().map((w) => ({ + chainType: w.chainType, + address: w.address, + })), + kycStatus: account.kycStatus, + status: account.status, + registeredAt: account.registeredAt, + lastLoginAt: account.lastLoginAt, + }, + }; + } + + @Get('my-devices') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '查询我的设备列表' }) + @ApiResponse({ status: 200, description: '查询成功' }) + async getMyDevices( + @CurrentUser() user: any, + @CurrentDeviceId() currentDeviceId: string, + ) { + const account = await this.userRepository.findById(user.userId); + if (!account) { + throw new ApplicationException('账户不存在'); + } + + const devices = account.getAllDevices().map((device) => ({ + deviceId: device.deviceId, + deviceName: device.deviceName, + addedAt: device.addedAt, + lastActiveAt: device.lastActiveAt, + isCurrent: device.deviceId === currentDeviceId, + })); + + return { + success: true, + data: devices, + }; + } + + @Delete('remove-device') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '移除设备' }) + @ApiResponse({ status: 200, description: '移除成功' }) + async removeDevice( + @Body() dto: RemoveDeviceDto, + @CurrentUser() user: any, + @CurrentDeviceId() currentDeviceId: string, + ) { + if (dto.deviceId === currentDeviceId) { + throw new ApplicationException('不能移除当前设备'); + } + + const account = await this.userRepository.findById(user.userId); + if (!account) { + throw new ApplicationException('账户不存在'); + } + + account.removeDevice(dto.deviceId); + await this.userRepository.save(account); + + // 吊销该设备的Token + await this.tokenService.revokeAllDeviceTokens(user.userId, dto.deviceId); + + return { + success: true, + message: '设备移除成功', + }; + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '退出登录' }) + @ApiResponse({ status: 200, description: '退出成功' }) + async logout( + @CurrentUser() user: any, + @CurrentDeviceId() deviceId: string, + ) { + await this.tokenService.revokeAllDeviceTokens(user.userId, deviceId); + + return { + success: true, + message: '退出成功', + }; + } +} diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts new file mode 100644 index 00000000..05d196f1 --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -0,0 +1,178 @@ +import { IsString, IsOptional, Length, IsNumber, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class AutoCreateAccountDto { + @ApiProperty({ + description: '设备唯一标识', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsString() + deviceId: string; + + @ApiPropertyOptional({ + description: '设备名称', + example: 'iPhone 15 Pro', + }) + @IsString() + @IsOptional() + deviceName?: string; + + @ApiPropertyOptional({ + description: '推荐码', + example: 'ABC123', + }) + @IsString() + @Length(6, 6) + @IsOptional() + inviterReferralCode?: string; + + @ApiPropertyOptional({ + description: '省份代码', + example: '110000', + }) + @IsString() + @IsOptional() + provinceCode?: string; + + @ApiPropertyOptional({ + description: '城市代码', + example: '110100', + }) + @IsString() + @IsOptional() + cityCode?: string; +} + +export class RecoverByMnemonicDto { + @ApiProperty({ + description: '账户序列号', + example: 10001, + }) + @IsNumber() + @Min(1) + @Type(() => Number) + accountSequence: number; + + @ApiProperty({ + description: '12个单词的助记词', + example: 'abandon ability able about above absent absorb abstract absurd abuse access accident', + }) + @IsString() + mnemonic: string; + + @ApiProperty({ + description: '新设备ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsString() + newDeviceId: string; + + @ApiPropertyOptional({ + description: '设备名称', + example: 'iPhone 15 Pro', + }) + @IsString() + @IsOptional() + deviceName?: string; +} + +export class RecoverByPhoneDto { + @ApiProperty({ + description: '账户序列号', + example: 10001, + }) + @IsNumber() + @Min(1) + @Type(() => Number) + accountSequence: number; + + @ApiProperty({ + description: '手机号', + example: '13800138000', + }) + @IsString() + @Length(11, 11) + phoneNumber: string; + + @ApiProperty({ + description: '短信验证码', + example: '123456', + }) + @IsString() + @Length(6, 6) + smsCode: string; + + @ApiProperty({ + description: '新设备ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsString() + newDeviceId: string; + + @ApiPropertyOptional({ + description: '设备名称', + example: 'iPhone 15 Pro', + }) + @IsString() + @IsOptional() + deviceName?: string; +} + +export class RefreshTokenDto { + @ApiProperty({ + description: 'Refresh Token', + }) + @IsString() + refreshToken: string; + + @ApiProperty({ + description: '设备ID', + }) + @IsString() + deviceId: string; +} + +export class SendSmsCodeDto { + @ApiProperty({ + description: '手机号', + example: '13800138000', + }) + @IsString() + @Length(11, 11) + phoneNumber: string; + + @ApiProperty({ + description: '验证码类型', + enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], + example: 'BIND', + }) + @IsString() + type: string; +} + +export class BindPhoneDto { + @ApiProperty({ + description: '手机号', + example: '13800138000', + }) + @IsString() + @Length(11, 11) + phoneNumber: string; + + @ApiProperty({ + description: '短信验证码', + example: '123456', + }) + @IsString() + @Length(6, 6) + smsCode: string; +} + +export class RemoveDeviceDto { + @ApiProperty({ + description: '要移除的设备ID', + }) + @IsString() + deviceId: string; +} diff --git a/backend/services/identity-service/src/api/dto/response/index.ts b/backend/services/identity-service/src/api/dto/response/index.ts new file mode 100644 index 00000000..f27fd1f1 --- /dev/null +++ b/backend/services/identity-service/src/api/dto/response/index.ts @@ -0,0 +1,162 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WalletAddressDto { + @ApiProperty() + chainType: string; + + @ApiProperty() + address: string; +} + +export class DeviceDto { + @ApiProperty() + deviceId: string; + + @ApiProperty() + deviceName: string; + + @ApiProperty() + addedAt: Date; + + @ApiProperty() + lastActiveAt: Date; + + @ApiProperty() + isCurrent: boolean; +} + +export class UserProfileDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiPropertyOptional() + phoneNumber?: string; + + @ApiProperty() + nickname: string; + + @ApiPropertyOptional() + avatarUrl?: string; + + @ApiProperty() + referralCode: string; + + @ApiProperty() + provinceCode: string; + + @ApiProperty() + cityCode: string; + + @ApiPropertyOptional() + address?: string; + + @ApiProperty({ type: [WalletAddressDto] }) + walletAddresses: WalletAddressDto[]; + + @ApiProperty() + kycStatus: string; + + @ApiProperty() + status: string; + + @ApiProperty() + registeredAt: Date; + + @ApiPropertyOptional() + lastLoginAt?: Date; +} + +export class AutoCreateAccountResponseDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty() + referralCode: string; + + @ApiProperty({ + description: '助记词(仅返回一次,请妥善保管)', + }) + mnemonic: string; + + @ApiProperty() + walletAddresses: { + kava: string; + dst: string; + bsc: string; + }; + + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; + + @ApiProperty() + accessTokenExpiresAt: Date; + + @ApiProperty() + refreshTokenExpiresAt: Date; +} + +export class RecoverAccountResponseDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty() + nickname: string; + + @ApiPropertyOptional() + avatarUrl?: string; + + @ApiProperty() + referralCode: string; + + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; + + @ApiProperty() + accessTokenExpiresAt: Date; + + @ApiProperty() + refreshTokenExpiresAt: Date; +} + +export class TokenResponseDto { + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; + + @ApiProperty() + accessTokenExpiresAt: Date; + + @ApiProperty() + refreshTokenExpiresAt: Date; +} + +export class ApiResponseDto { + @ApiProperty() + success: boolean; + + @ApiProperty() + message: string; + + @ApiPropertyOptional() + data?: T; + + @ApiPropertyOptional() + error?: string; +} diff --git a/backend/services/identity-service/src/app.module.ts b/backend/services/identity-service/src/app.module.ts new file mode 100644 index 00000000..1d7637cc --- /dev/null +++ b/backend/services/identity-service/src/app.module.ts @@ -0,0 +1,92 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { CqrsModule } from '@nestjs/cqrs'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ScheduleModule } from '@nestjs/schedule'; + +// Infrastructure +import { PrismaService } from './infrastructure/persistence/prisma/prisma.service'; +import { UserAccountMapper } from './infrastructure/persistence/mappers/user-account.mapper'; +import { UserAccountRepositoryImpl } from './infrastructure/persistence/repositories/user-account.repository.impl'; +import { RedisModule } from './infrastructure/redis/redis.module'; +import { KafkaModule } from './infrastructure/kafka/kafka.module'; +import { WalletGeneratorService } from './infrastructure/external/blockchain/wallet-generator.service'; +import { SmsService } from './infrastructure/external/sms/sms.service'; + +// Domain +import { USER_ACCOUNT_REPOSITORY } from './domain/repositories/user-account.repository.interface'; +import { UserValidatorService } from './domain/services/user-validator.service'; + +// Application +import { TokenService } from './application/services/token.service'; +import { AutoCreateAccountHandler } from './application/commands/auto-create-account/auto-create-account.handler'; +import { RecoverByMnemonicHandler } from './application/commands/recover-by-mnemonic/recover-by-mnemonic.handler'; +import { RecoverByPhoneHandler } from './application/commands/recover-by-phone/recover-by-phone.handler'; + +// API +import { UserAccountController } from './api/controllers/user-account.controller'; + +// Shared +import { JwtStrategy } from './shared/strategies/jwt.strategy'; +import { JwtAuthGuard } from './shared/guards/jwt-auth.guard'; + +const CommandHandlers = [ + AutoCreateAccountHandler, + RecoverByMnemonicHandler, + RecoverByPhoneHandler, +]; + +const QueryHandlers = []; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', `.env.${process.env.NODE_ENV || 'development'}`], + }), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'default-secret'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '2h'), + }, + }), + inject: [ConfigService], + }), + ScheduleModule.forRoot(), + CqrsModule, + RedisModule, + KafkaModule, + ], + controllers: [UserAccountController], + providers: [ + // Infrastructure + PrismaService, + UserAccountMapper, + { + provide: USER_ACCOUNT_REPOSITORY, + useClass: UserAccountRepositoryImpl, + }, + WalletGeneratorService, + SmsService, + + // Domain Services + UserValidatorService, + + // Application Services + TokenService, + + // Auth + JwtStrategy, + JwtAuthGuard, + + // CQRS Handlers + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [PrismaService], +}) +export class AppModule {} 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 new file mode 100644 index 00000000..d3ec2ca6 --- /dev/null +++ b/backend/services/identity-service/src/application/commands/auto-create-account/auto-create-account.command.ts @@ -0,0 +1,9 @@ +export class AutoCreateAccountCommand { + constructor( + public readonly deviceId: string, + public readonly deviceName: string | undefined, + public readonly inviterReferralCode: string | undefined, + public readonly provinceCode: string | undefined, + public readonly cityCode: string | undefined, + ) {} +} 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 new file mode 100644 index 00000000..87745ec0 --- /dev/null +++ b/backend/services/identity-service/src/application/commands/auto-create-account/auto-create-account.handler.ts @@ -0,0 +1,147 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Inject, Logger } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { AutoCreateAccountCommand } from './auto-create-account.command'; +import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate'; +import { ReferralCode } from '@domain/value-objects/referral-code.vo'; +import { UserValidatorService } from '@domain/services/user-validator.service'; +import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service'; +import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service'; +import { + IUserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '@domain/repositories/user-account.repository.interface'; +import { TokenService } from '@application/services/token.service'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +export interface AutoCreateAccountResult { + userId: string; + accountSequence: number; + referralCode: string; + mnemonic: string; + walletAddresses: { + kava: string; + dst: string; + bsc: string; + }; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; +} + +@CommandHandler(AutoCreateAccountCommand) +export class AutoCreateAccountHandler + implements ICommandHandler +{ + private readonly logger = new Logger(AutoCreateAccountHandler.name); + + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: IUserAccountRepository, + private readonly validatorService: UserValidatorService, + private readonly walletGenerator: WalletGeneratorService, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute( + command: AutoCreateAccountCommand, + ): Promise { + this.logger.log(`Creating account for device: ${command.deviceId}`); + + // 1. 验证设备ID + const deviceValidation = await this.validatorService.validateDeviceId( + command.deviceId, + ); + + if (!deviceValidation.isValid) { + throw new ApplicationException(deviceValidation.errorMessage!); + } + + // 2. 验证推荐码(如果有) + let inviterSequence = null; + + if (command.inviterReferralCode) { + const referralCode = ReferralCode.create(command.inviterReferralCode); + const referralValidation = + await this.validatorService.validateReferralCode(referralCode); + + if (!referralValidation.isValid) { + throw new ApplicationException(referralValidation.errorMessage!); + } + + const inviter = await this.userRepository.findByReferralCode(referralCode); + inviterSequence = inviter!.accountSequence; + } + + // 3. 生成账户序列号 + const accountSequence = await this.userRepository.getNextAccountSequence(); + + // 4. 生成用户ID + const userId = String(accountSequence.value); + + // 5. 创建用户账户 + const account = UserAccount.createAutomatic({ + userId, + accountSequence, + initialDeviceId: command.deviceId, + deviceName: command.deviceName, + inviterSequence, + provinceCode: command.provinceCode || 'DEFAULT', + cityCode: command.cityCode || 'DEFAULT', + }); + + // 6. 生成钱包 + const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({ + userId: account.userId, + deviceId: command.deviceId, + }); + + // 7. 绑定钱包 + account.bindMultipleWalletAddresses(wallets); + + // 8. 保存 + await this.userRepository.save(account); + await this.userRepository.saveWallets( + account.userId, + Array.from(wallets.values()), + ); + + // 9. 生成Token + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + // 10. 发布事件 + await this.eventPublisher.publishUserAccountCreated({ + userId: account.userId, + accountSequence: account.accountSequence.value, + initialDeviceId: command.deviceId, + inviterSequence: inviterSequence?.value || null, + provinceCode: account.provinceCode, + cityCode: account.cityCode, + referralCode: account.referralCode.value, + }); + + this.logger.log(`Account created: ${account.userId}`); + + return { + userId: account.userId, + accountSequence: account.accountSequence.value, + referralCode: account.referralCode.value, + mnemonic: mnemonic.value, + walletAddresses: { + kava: wallets.get('KAVA' as any)!.address, + dst: wallets.get('DST' as any)!.address, + bsc: wallets.get('BSC' as any)!.address, + }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + }; + } +} 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 new file mode 100644 index 00000000..19b6e37f --- /dev/null +++ b/backend/services/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.command.ts @@ -0,0 +1,8 @@ +export class RecoverByMnemonicCommand { + constructor( + public readonly accountSequence: number, + public readonly mnemonic: string, + public readonly newDeviceId: string, + public readonly deviceName: string | undefined, + ) {} +} 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 new file mode 100644 index 00000000..3407c725 --- /dev/null +++ b/backend/services/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.handler.ts @@ -0,0 +1,120 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Inject, Logger } from '@nestjs/common'; +import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command'; +import { AccountSequence } from '@domain/value-objects/account-sequence.vo'; +import { Mnemonic } from '@domain/value-objects/mnemonic.vo'; +import { ChainType } from '@domain/enums/chain-type.enum'; +import { WalletGeneratorService } from '@infrastructure/external/blockchain/wallet-generator.service'; +import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service'; +import { + IUserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '@domain/repositories/user-account.repository.interface'; +import { TokenService } from '@application/services/token.service'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +export interface RecoverAccountResult { + userId: string; + accountSequence: number; + nickname: string; + avatarUrl: string | null; + referralCode: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; +} + +@CommandHandler(RecoverByMnemonicCommand) +export class RecoverByMnemonicHandler + implements ICommandHandler +{ + private readonly logger = new Logger(RecoverByMnemonicHandler.name); + + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: IUserAccountRepository, + private readonly walletGenerator: WalletGeneratorService, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute( + command: RecoverByMnemonicCommand, + ): Promise { + this.logger.log(`Recovering account: ${command.accountSequence}`); + + // 1. 查找账户 + const accountSequence = AccountSequence.create(command.accountSequence); + const account = + await this.userRepository.findByAccountSequence(accountSequence); + + if (!account) { + throw new ApplicationException('账户序列号不存在'); + } + + if (!account.isActive) { + throw new ApplicationException('账户已冻结或注销'); + } + + // 2. 验证助记词 + let mnemonic: Mnemonic; + try { + mnemonic = Mnemonic.create(command.mnemonic); + } catch { + throw new ApplicationException('助记词格式错误'); + } + + // 3. 验证助记词是否匹配账户 + const kavaWallet = account.getWalletAddress(ChainType.KAVA); + if (!kavaWallet) { + throw new ApplicationException('账户钱包信息异常'); + } + + const isValid = this.walletGenerator.verifyMnemonic( + mnemonic, + ChainType.KAVA, + kavaWallet.address, + ); + + if (!isValid) { + throw new ApplicationException('助记词错误'); + } + + // 4. 添加新设备 + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(command.newDeviceId); + + // 5. 保存更新 + await this.userRepository.save(account); + + // 6. 生成Token + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + // 7. 发布事件 + await this.eventPublisher.publishDeviceAdded({ + userId: account.userId, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + deviceName: command.deviceName || '未命名设备', + }); + + this.logger.log(`Account recovered: ${account.userId}`); + + return { + userId: account.userId, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + }; + } +} 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 new file mode 100644 index 00000000..f6d63a91 --- /dev/null +++ b/backend/services/identity-service/src/application/commands/recover-by-phone/recover-by-phone.command.ts @@ -0,0 +1,9 @@ +export class RecoverByPhoneCommand { + constructor( + public readonly accountSequence: number, + public readonly phoneNumber: string, + public readonly smsCode: string, + public readonly newDeviceId: string, + public readonly deviceName: string | undefined, + ) {} +} 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 new file mode 100644 index 00000000..eccff1eb --- /dev/null +++ b/backend/services/identity-service/src/application/commands/recover-by-phone/recover-by-phone.handler.ts @@ -0,0 +1,110 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Inject, Logger } from '@nestjs/common'; +import { RecoverByPhoneCommand } from './recover-by-phone.command'; +import { AccountSequence } from '@domain/value-objects/account-sequence.vo'; +import { PhoneNumber } from '@domain/value-objects/phone-number.vo'; +import { SmsService, SmsType } from '@infrastructure/external/sms/sms.service'; +import { EventPublisherService } from '@infrastructure/kafka/event-publisher.service'; +import { + IUserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '@domain/repositories/user-account.repository.interface'; +import { TokenService } from '@application/services/token.service'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +export interface RecoverByPhoneResult { + userId: string; + accountSequence: number; + nickname: string; + avatarUrl: string | null; + referralCode: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; +} + +@CommandHandler(RecoverByPhoneCommand) +export class RecoverByPhoneHandler + implements ICommandHandler +{ + private readonly logger = new Logger(RecoverByPhoneHandler.name); + + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: IUserAccountRepository, + private readonly smsService: SmsService, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute(command: RecoverByPhoneCommand): Promise { + this.logger.log(`Recovering account by phone: ${command.accountSequence}`); + + // 1. 查找账户 + const accountSequence = AccountSequence.create(command.accountSequence); + const account = + await this.userRepository.findByAccountSequence(accountSequence); + + if (!account) { + throw new ApplicationException('账户序列号不存在'); + } + + if (!account.isActive) { + throw new ApplicationException('账户已冻结或注销'); + } + + // 2. 验证手机号是否匹配 + if (!account.phoneNumber) { + throw new ApplicationException('该账户未绑定手机号,请使用助记词恢复'); + } + + const phoneNumber = PhoneNumber.create(command.phoneNumber); + if (!account.phoneNumber.equals(phoneNumber)) { + throw new ApplicationException('手机号不匹配'); + } + + // 3. 验证短信验证码 + await this.smsService.verifyCode( + command.phoneNumber, + SmsType.RECOVER, + command.smsCode, + ); + + // 4. 添加新设备 + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(command.newDeviceId); + + // 5. 保存更新 + await this.userRepository.save(account); + + // 6. 生成Token + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + // 7. 发布事件 + await this.eventPublisher.publishDeviceAdded({ + userId: account.userId, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + deviceName: command.deviceName || '未命名设备', + }); + + this.logger.log(`Account recovered by phone: ${account.userId}`); + + return { + userId: account.userId, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + }; + } +} diff --git a/backend/services/identity-service/src/application/services/token.service.ts b/backend/services/identity-service/src/application/services/token.service.ts new file mode 100644 index 00000000..9e01b452 --- /dev/null +++ b/backend/services/identity-service/src/application/services/token.service.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { createHash } from 'crypto'; +import { RedisService } from '@infrastructure/redis/redis.service'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +export interface TokenPair { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; +} + +export interface TokenPayload { + userId: string; + accountSequence: number; + deviceId: string; +} + +export interface DecodedToken extends TokenPayload { + type: 'access' | 'refresh'; + iat: number; + exp: number; +} + +@Injectable() +export class TokenService { + private readonly logger = new Logger(TokenService.name); + private readonly accessTokenExpiration: string; + private readonly refreshTokenExpiration: string; + private readonly jwtSecret: string; + + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) { + this.jwtSecret = configService.get('JWT_SECRET', 'default-secret'); + this.accessTokenExpiration = configService.get('JWT_ACCESS_EXPIRATION', '2h'); + this.refreshTokenExpiration = configService.get('JWT_REFRESH_EXPIRATION', '30d'); + } + + async generateTokenPair(payload: TokenPayload): Promise { + const now = new Date(); + + const accessToken = await this.jwtService.signAsync( + { + ...payload, + type: 'access', + }, + { + secret: this.jwtSecret, + expiresIn: this.accessTokenExpiration, + }, + ); + + const refreshToken = await this.jwtService.signAsync( + { + ...payload, + type: 'refresh', + }, + { + secret: this.jwtSecret, + expiresIn: this.refreshTokenExpiration, + }, + ); + + // 计算过期时间 + const accessTokenExpiresAt = this.calculateExpirationDate( + this.accessTokenExpiration, + now, + ); + const refreshTokenExpiresAt = this.calculateExpirationDate( + this.refreshTokenExpiration, + now, + ); + + this.logger.debug(`Generated token pair for user: ${payload.userId}`); + + return { + accessToken, + refreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + }; + } + + async verifyAccessToken(token: string): Promise { + 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, + }); + } + + 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天 + } + } + + if (ttl > 0) { + await this.redisService.addToBlacklist(tokenHash, ttl); + this.logger.debug(`Token revoked: ${tokenHash.substring(0, 16)}...`); + } + } + + async revokeAllDeviceTokens( + userId: string, + deviceId: string, + ): Promise { + // 标记设备的所有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}`); + } + + 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/domain/aggregates/user-account/index.ts b/backend/services/identity-service/src/domain/aggregates/user-account/index.ts new file mode 100644 index 00000000..b6df9b6c --- /dev/null +++ b/backend/services/identity-service/src/domain/aggregates/user-account/index.ts @@ -0,0 +1 @@ +export * from './user-account.aggregate'; 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 new file mode 100644 index 00000000..325cc9fa --- /dev/null +++ b/backend/services/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts @@ -0,0 +1,470 @@ +import { AggregateRoot } from '@nestjs/cqrs'; +import { AccountSequence } from '../../value-objects/account-sequence.vo'; +import { PhoneNumber } from '../../value-objects/phone-number.vo'; +import { ReferralCode } from '../../value-objects/referral-code.vo'; +import { DeviceInfo } from '../../value-objects/device-info.vo'; +import { KYCInfo } from '../../value-objects/kyc-info.vo'; +import { WalletAddress } from '../../entities/wallet-address.entity'; +import { ChainType } from '../../enums/chain-type.enum'; +import { KYCStatus } from '../../enums/kyc-status.enum'; +import { AccountStatus } from '../../enums/account-status.enum'; +import { DomainException } from '@shared/exceptions/domain.exception'; +import { UserAccountCreatedEvent } from '../../events/user-account-created.event'; +import { DeviceAddedEvent } from '../../events/device-added.event'; +import { PhoneNumberBoundEvent } from '../../events/phone-number-bound.event'; +import { KYCSubmittedEvent } from '../../events/kyc-submitted.event'; + +export interface UserAccountProps { + userId: string; + accountSequence: AccountSequence; + devices: Map; + 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; + 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 _address: string | null; + private _walletAddresses: Map; + private _kycInfo: KYCInfo | null; + private _kycStatus: KYCStatus; + private _status: AccountStatus; + private _registeredAt: Date; + private _lastLoginAt: Date | null; + private _updatedAt: Date; + + private constructor() { + super(); + } + + // Getters + get userId(): string { + return this._userId; + } + + get accountSequence(): AccountSequence { + return this._accountSequence; + } + + get phoneNumber(): PhoneNumber | null { + return this._phoneNumber; + } + + get nickname(): string { + return this._nickname; + } + + get avatarUrl(): string | null { + return this._avatarUrl; + } + + get inviterSequence(): AccountSequence | null { + return this._inviterSequence; + } + + get referralCode(): ReferralCode { + return this._referralCode; + } + + get provinceCode(): string { + return this._provinceCode; + } + + get cityCode(): string { + return this._cityCode; + } + + get address(): string | null { + return this._address; + } + + get kycInfo(): KYCInfo | null { + return this._kycInfo; + } + + get kycStatus(): KYCStatus { + return this._kycStatus; + } + + get status(): AccountStatus { + return this._status; + } + + get registeredAt(): Date { + return this._registeredAt; + } + + get lastLoginAt(): Date | null { + return this._lastLoginAt; + } + + get updatedAt(): Date { + return this._updatedAt; + } + + get isActive(): boolean { + return this._status === AccountStatus.ACTIVE; + } + + get isKYCVerified(): boolean { + return this._kycStatus === KYCStatus.VERIFIED; + } + + // Static Factory Methods + static createAutomatic(params: { + userId: string; + accountSequence: AccountSequence; + initialDeviceId: string; + deviceName?: string; + inviterSequence: AccountSequence | null; + provinceCode: string; + cityCode: string; + }): UserAccount { + const account = new UserAccount(); + + const devices = new Map(); + const now = new Date(); + devices.set( + params.initialDeviceId, + new DeviceInfo( + params.initialDeviceId, + params.deviceName || '未命名设备', + now, + now, + ), + ); + + const referralCode = ReferralCode.generate(); + + account._userId = params.userId; + account._accountSequence = params.accountSequence; + account._devices = devices; + account._phoneNumber = null; + account._nickname = `用户${params.accountSequence.value}`; + account._avatarUrl = null; + account._inviterSequence = params.inviterSequence; + account._referralCode = referralCode; + account._provinceCode = params.provinceCode; + account._cityCode = params.cityCode; + account._address = null; + account._walletAddresses = new Map(); + account._kycInfo = null; + account._kycStatus = KYCStatus.NOT_VERIFIED; + account._status = AccountStatus.ACTIVE; + account._registeredAt = now; + account._lastLoginAt = null; + account._updatedAt = now; + + account.apply( + new UserAccountCreatedEvent( + account._userId, + params.accountSequence.value, + params.initialDeviceId, + params.inviterSequence?.value || null, + params.provinceCode, + params.cityCode, + referralCode.value, + ), + ); + + return account; + } + + static fromPersistence(props: UserAccountProps): UserAccount { + const account = new UserAccount(); + + account._userId = props.userId; + account._accountSequence = props.accountSequence; + account._devices = props.devices; + account._phoneNumber = props.phoneNumber; + account._nickname = props.nickname; + account._avatarUrl = props.avatarUrl; + account._inviterSequence = props.inviterSequence; + account._referralCode = props.referralCode; + account._provinceCode = props.provinceCode; + account._cityCode = props.cityCode; + account._address = props.address; + account._walletAddresses = props.walletAddresses; + account._kycInfo = props.kycInfo; + account._kycStatus = props.kycStatus; + account._status = props.status; + account._registeredAt = props.registeredAt; + account._lastLoginAt = props.lastLoginAt; + account._updatedAt = props.updatedAt; + + return account; + } + + // Domain Methods + addDevice(deviceId: string, deviceName?: string): void { + this.ensureActive(); + + if (this._devices.size >= 5 && !this._devices.has(deviceId)) { + throw new DomainException('最多允许5个设备同时登录'); + } + + const now = new Date(); + + if (this._devices.has(deviceId)) { + this._devices.get(deviceId)!.updateActivity(); + } else { + this._devices.set( + deviceId, + new DeviceInfo( + deviceId, + deviceName || '未命名设备', + now, + now, + ), + ); + + this.apply( + new DeviceAddedEvent( + this._userId, + this._accountSequence.value, + deviceId, + deviceName || '未命名设备', + ), + ); + } + + this._updatedAt = now; + } + + removeDevice(deviceId: string): void { + this.ensureActive(); + + if (!this._devices.has(deviceId)) { + throw new DomainException('设备不存在'); + } + + if (this._devices.size <= 1) { + throw new DomainException('至少保留一个设备'); + } + + this._devices.delete(deviceId); + this._updatedAt = new Date(); + } + + isDeviceAuthorized(deviceId: string): boolean { + return this._devices.has(deviceId); + } + + getAllDevices(): DeviceInfo[] { + return Array.from(this._devices.values()); + } + + getDeviceCount(): number { + return this._devices.size; + } + + updateDeviceActivity(deviceId: string): void { + if (this._devices.has(deviceId)) { + this._devices.get(deviceId)!.updateActivity(); + this._updatedAt = new Date(); + } + } + + bindPhoneNumber(phoneNumber: PhoneNumber): void { + this.ensureActive(); + + if (this._phoneNumber) { + throw new DomainException('已绑定手机号,不可重复绑定'); + } + + this._phoneNumber = phoneNumber; + this._updatedAt = new Date(); + + this.apply(new PhoneNumberBoundEvent(this._userId, phoneNumber.value)); + } + + bindWalletAddress(wallet: WalletAddress): void { + this.ensureActive(); + + if (this._walletAddresses.has(wallet.chainType)) { + throw new DomainException(`已绑定${wallet.chainType}地址`); + } + + this._walletAddresses.set(wallet.chainType, wallet); + this._updatedAt = new Date(); + } + + bindMultipleWalletAddresses(wallets: Map): void { + this.ensureActive(); + + for (const [chainType, wallet] of wallets) { + if (this._walletAddresses.has(chainType)) { + throw new DomainException(`已绑定${chainType}地址`); + } + this._walletAddresses.set(chainType, wallet); + } + + this._updatedAt = new Date(); + } + + getWalletAddress(chainType: ChainType): WalletAddress | null { + return this._walletAddresses.get(chainType) || null; + } + + getAllWalletAddresses(): WalletAddress[] { + return Array.from(this._walletAddresses.values()); + } + + submitKYC(kycInfo: KYCInfo): void { + this.ensureActive(); + + if (this._kycStatus === KYCStatus.VERIFIED) { + throw new DomainException('已通过KYC认证,不可重复提交'); + } + + this._kycInfo = kycInfo; + this._kycStatus = KYCStatus.PENDING; + this._updatedAt = new Date(); + + this.apply( + new KYCSubmittedEvent( + this._userId, + kycInfo.realName, + kycInfo.idCardNumber, + ), + ); + } + + approveKYC(): void { + if (this._kycStatus !== KYCStatus.PENDING) { + throw new DomainException('只有待审核状态才能通过KYC'); + } + + this._kycStatus = KYCStatus.VERIFIED; + this._updatedAt = new Date(); + } + + rejectKYC(reason: string): void { + if (this._kycStatus !== KYCStatus.PENDING) { + throw new DomainException('只有待审核状态才能拒绝KYC'); + } + + this._kycStatus = KYCStatus.REJECTED; + this._updatedAt = new Date(); + } + + recordLogin(deviceId: string): void { + this.ensureActive(); + + if (!this._devices.has(deviceId)) { + throw new DomainException('设备未授权'); + } + + this._devices.get(deviceId)!.updateActivity(); + this._lastLoginAt = new Date(); + this._updatedAt = new Date(); + } + + updateProfile(params: { + nickname?: string; + avatarUrl?: string; + address?: string; + }): void { + this.ensureActive(); + + if (params.nickname !== undefined) { + if (params.nickname.trim().length < 2) { + throw new DomainException('昵称至少需要2个字符'); + } + this._nickname = params.nickname; + } + + if (params.avatarUrl !== undefined) { + this._avatarUrl = params.avatarUrl; + } + + if (params.address !== undefined) { + this._address = params.address; + } + + this._updatedAt = new Date(); + } + + freeze(reason: string): void { + if (this._status === AccountStatus.FROZEN) { + throw new DomainException('账户已冻结'); + } + + if (this._status === AccountStatus.DEACTIVATED) { + throw new DomainException('账户已注销,无法冻结'); + } + + this._status = AccountStatus.FROZEN; + this._updatedAt = new Date(); + } + + unfreeze(): void { + if (this._status !== AccountStatus.FROZEN) { + throw new DomainException('账户未冻结'); + } + + this._status = AccountStatus.ACTIVE; + this._updatedAt = new Date(); + } + + deactivate(): void { + if (this._status === AccountStatus.DEACTIVATED) { + throw new DomainException('账户已注销'); + } + + this._status = AccountStatus.DEACTIVATED; + this._updatedAt = new Date(); + } + + private ensureActive(): void { + if (this._status !== AccountStatus.ACTIVE) { + throw new DomainException('账户已冻结或注销'); + } + } + + // Persistence helpers + toPersistenceData(): object { + return { + userId: this._userId, + accountSequence: this._accountSequence.value, + phoneNumber: this._phoneNumber?.value || null, + nickname: this._nickname, + avatarUrl: this._avatarUrl, + inviterSequence: this._inviterSequence?.value || null, + referralCode: this._referralCode.value, + provinceCode: this._provinceCode, + cityCode: this._cityCode, + address: this._address, + kycStatus: this._kycStatus, + realName: this._kycInfo?.realName || null, + idCardNumber: this._kycInfo?.idCardNumber || null, + idCardFrontUrl: this._kycInfo?.idCardFrontUrl || null, + idCardBackUrl: this._kycInfo?.idCardBackUrl || null, + status: this._status, + lastLoginAt: this._lastLoginAt, + updatedAt: this._updatedAt, + }; + } +} diff --git a/backend/services/identity-service/src/domain/entities/index.ts b/backend/services/identity-service/src/domain/entities/index.ts new file mode 100644 index 00000000..2adfbd73 --- /dev/null +++ b/backend/services/identity-service/src/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './wallet-address.entity'; 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 new file mode 100644 index 00000000..d5d6a47c --- /dev/null +++ b/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts @@ -0,0 +1,90 @@ +import { ChainType } from '../enums/chain-type.enum'; + +export class WalletAddress { + private constructor( + private readonly _addressId: string, + private readonly _userId: string, + private readonly _chainType: ChainType, + private readonly _address: string, + private readonly _encryptedMnemonic: string, + private _status: 'ACTIVE' | 'DISABLED', + private readonly _boundAt: Date, + ) {} + + static create(params: { + addressId: string; + userId: string; + chainType: ChainType; + address: string; + encryptedMnemonic: string; + status?: 'ACTIVE' | 'DISABLED'; + boundAt?: Date; + }): WalletAddress { + return new WalletAddress( + params.addressId, + params.userId, + params.chainType, + params.address, + params.encryptedMnemonic, + params.status || 'ACTIVE', + params.boundAt || new Date(), + ); + } + + get addressId(): string { + return this._addressId; + } + + get userId(): string { + return this._userId; + } + + get chainType(): ChainType { + return this._chainType; + } + + get address(): string { + return this._address; + } + + get encryptedMnemonic(): string { + return this._encryptedMnemonic; + } + + get status(): 'ACTIVE' | 'DISABLED' { + return this._status; + } + + get boundAt(): Date { + return this._boundAt; + } + + get isActive(): boolean { + return this._status === 'ACTIVE'; + } + + disable(): void { + this._status = 'DISABLED'; + } + + enable(): void { + this._status = 'ACTIVE'; + } + + maskedAddress(): string { + if (this._address.length <= 12) { + return this._address; + } + return `${this._address.slice(0, 8)}...${this._address.slice(-4)}`; + } + + toJSON(): object { + return { + addressId: this._addressId, + chainType: this._chainType, + address: this._address, + status: this._status, + boundAt: this._boundAt.toISOString(), + }; + } +} diff --git a/backend/services/identity-service/src/domain/enums/account-status.enum.ts b/backend/services/identity-service/src/domain/enums/account-status.enum.ts new file mode 100644 index 00000000..0757829f --- /dev/null +++ b/backend/services/identity-service/src/domain/enums/account-status.enum.ts @@ -0,0 +1,5 @@ +export enum AccountStatus { + ACTIVE = 'ACTIVE', + FROZEN = 'FROZEN', + DEACTIVATED = 'DEACTIVATED', +} diff --git a/backend/services/identity-service/src/domain/enums/chain-type.enum.ts b/backend/services/identity-service/src/domain/enums/chain-type.enum.ts new file mode 100644 index 00000000..e8dbb985 --- /dev/null +++ b/backend/services/identity-service/src/domain/enums/chain-type.enum.ts @@ -0,0 +1,20 @@ +export enum ChainType { + KAVA = 'KAVA', + DST = 'DST', + BSC = 'BSC', +} + +export const CHAIN_CONFIG = { + [ChainType.KAVA]: { + prefix: 'kava', + derivationPath: "m/44'/459'/0'/0/0", + }, + [ChainType.DST]: { + prefix: 'dst', + derivationPath: "m/44'/118'/0'/0/0", + }, + [ChainType.BSC]: { + prefix: '0x', + derivationPath: "m/44'/60'/0'/0/0", + }, +}; diff --git a/backend/services/identity-service/src/domain/enums/index.ts b/backend/services/identity-service/src/domain/enums/index.ts new file mode 100644 index 00000000..515b887c --- /dev/null +++ b/backend/services/identity-service/src/domain/enums/index.ts @@ -0,0 +1,3 @@ +export * from './chain-type.enum'; +export * from './kyc-status.enum'; +export * from './account-status.enum'; diff --git a/backend/services/identity-service/src/domain/enums/kyc-status.enum.ts b/backend/services/identity-service/src/domain/enums/kyc-status.enum.ts new file mode 100644 index 00000000..bb5ba5a3 --- /dev/null +++ b/backend/services/identity-service/src/domain/enums/kyc-status.enum.ts @@ -0,0 +1,6 @@ +export enum KYCStatus { + NOT_VERIFIED = 'NOT_VERIFIED', + PENDING = 'PENDING', + VERIFIED = 'VERIFIED', + REJECTED = 'REJECTED', +} 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 new file mode 100644 index 00000000..1d7aa621 --- /dev/null +++ b/backend/services/identity-service/src/domain/events/device-added.event.ts @@ -0,0 +1,33 @@ +import { DomainEvent } from './domain-event.base'; + +export class DeviceAddedEvent extends DomainEvent { + constructor( + public readonly userId: string, + public readonly accountSequence: number, + public readonly deviceId: string, + public readonly deviceName: string, + ) { + super(); + } + + get eventType(): string { + return 'DeviceAdded'; + } + + get aggregateId(): string { + return this.userId; + } + + get aggregateType(): string { + return 'UserAccount'; + } + + toPayload(): object { + return { + userId: this.userId, + accountSequence: this.accountSequence, + deviceId: this.deviceId, + deviceName: this.deviceName, + }; + } +} diff --git a/backend/services/identity-service/src/domain/events/domain-event.base.ts b/backend/services/identity-service/src/domain/events/domain-event.base.ts new file mode 100644 index 00000000..357c5013 --- /dev/null +++ b/backend/services/identity-service/src/domain/events/domain-event.base.ts @@ -0,0 +1,15 @@ +import { v4 as uuidv4 } from 'uuid'; + +export abstract class DomainEvent { + public readonly eventId: string; + public readonly occurredAt: Date; + + constructor() { + this.eventId = uuidv4(); + this.occurredAt = new Date(); + } + + abstract get eventType(): string; + abstract get aggregateId(): string; + abstract get aggregateType(): string; +} diff --git a/backend/services/identity-service/src/domain/events/index.ts b/backend/services/identity-service/src/domain/events/index.ts new file mode 100644 index 00000000..a823883c --- /dev/null +++ b/backend/services/identity-service/src/domain/events/index.ts @@ -0,0 +1,5 @@ +export * from './domain-event.base'; +export * from './user-account-created.event'; +export * from './device-added.event'; +export * from './phone-number-bound.event'; +export * from './kyc-submitted.event'; diff --git a/backend/services/identity-service/src/domain/events/kyc-submitted.event.ts b/backend/services/identity-service/src/domain/events/kyc-submitted.event.ts new file mode 100644 index 00000000..359231de --- /dev/null +++ b/backend/services/identity-service/src/domain/events/kyc-submitted.event.ts @@ -0,0 +1,31 @@ +import { DomainEvent } from './domain-event.base'; + +export class KYCSubmittedEvent extends DomainEvent { + constructor( + public readonly userId: string, + public readonly realName: string, + public readonly idCardNumber: string, + ) { + super(); + } + + get eventType(): string { + return 'KYCSubmitted'; + } + + get aggregateId(): string { + return this.userId; + } + + get aggregateType(): string { + return 'UserAccount'; + } + + toPayload(): object { + return { + userId: this.userId, + realName: this.realName, + idCardNumber: this.idCardNumber, + }; + } +} diff --git a/backend/services/identity-service/src/domain/events/phone-number-bound.event.ts b/backend/services/identity-service/src/domain/events/phone-number-bound.event.ts new file mode 100644 index 00000000..58c95c3c --- /dev/null +++ b/backend/services/identity-service/src/domain/events/phone-number-bound.event.ts @@ -0,0 +1,29 @@ +import { DomainEvent } from './domain-event.base'; + +export class PhoneNumberBoundEvent extends DomainEvent { + constructor( + public readonly userId: string, + public readonly phoneNumber: string, + ) { + super(); + } + + get eventType(): string { + return 'PhoneNumberBound'; + } + + get aggregateId(): string { + return this.userId; + } + + get aggregateType(): string { + return 'UserAccount'; + } + + toPayload(): object { + return { + userId: this.userId, + phoneNumber: this.phoneNumber, + }; + } +} 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 new file mode 100644 index 00000000..daceb8db --- /dev/null +++ b/backend/services/identity-service/src/domain/events/user-account-created.event.ts @@ -0,0 +1,39 @@ +import { DomainEvent } from './domain-event.base'; + +export class UserAccountCreatedEvent extends DomainEvent { + constructor( + public readonly userId: string, + public readonly accountSequence: number, + public readonly initialDeviceId: string, + public readonly inviterSequence: number | null, + public readonly provinceCode: string, + public readonly cityCode: string, + public readonly referralCode: string, + ) { + super(); + } + + get eventType(): string { + return 'UserAccountCreated'; + } + + get aggregateId(): string { + return this.userId; + } + + get aggregateType(): string { + return 'UserAccount'; + } + + toPayload(): object { + return { + userId: this.userId, + accountSequence: this.accountSequence, + initialDeviceId: this.initialDeviceId, + inviterSequence: this.inviterSequence, + provinceCode: this.provinceCode, + cityCode: this.cityCode, + referralCode: this.referralCode, + }; + } +} diff --git a/backend/services/identity-service/src/domain/repositories/index.ts b/backend/services/identity-service/src/domain/repositories/index.ts new file mode 100644 index 00000000..42cdcadb --- /dev/null +++ b/backend/services/identity-service/src/domain/repositories/index.ts @@ -0,0 +1 @@ +export * from './user-account.repository.interface'; 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 new file mode 100644 index 00000000..271240a7 --- /dev/null +++ b/backend/services/identity-service/src/domain/repositories/user-account.repository.interface.ts @@ -0,0 +1,78 @@ +import { UserAccount } from '../aggregates/user-account/user-account.aggregate'; +import { AccountSequence } from '../value-objects/account-sequence.vo'; +import { PhoneNumber } from '../value-objects/phone-number.vo'; +import { ReferralCode } from '../value-objects/referral-code.vo'; +import { ChainType } from '../enums/chain-type.enum'; +import { WalletAddress } from '../entities/wallet-address.entity'; + +export interface IUserAccountRepository { + /** + * 保存用户账户 + */ + save(account: UserAccount): Promise; + + /** + * 保存钱包地址 + */ + saveWallets(userId: string, wallets: WalletAddress[]): Promise; + + /** + * 根据用户ID查找账户 + */ + findById(userId: string): Promise; + + /** + * 根据账户序列号查找账户 + */ + findByAccountSequence(sequence: AccountSequence): Promise; + + /** + * 根据设备ID查找账户 + */ + findByDeviceId(deviceId: string): Promise; + + /** + * 根据手机号查找账户 + */ + findByPhoneNumber(phoneNumber: PhoneNumber): Promise; + + /** + * 根据推荐码查找账户 + */ + findByReferralCode(referralCode: ReferralCode): Promise; + + /** + * 根据钱包地址查找账户 + */ + findByWalletAddress( + chainType: ChainType, + address: string, + ): Promise; + + /** + * 获取最大账户序列号 + */ + getNextAccountSequence(): Promise; + + /** + * 检查设备ID是否已存在 + */ + existsByDeviceId(deviceId: string): Promise; + + /** + * 检查手机号是否已存在 + */ + existsByPhoneNumber(phoneNumber: PhoneNumber): Promise; + + /** + * 检查推荐码是否存在 + */ + existsByReferralCode(referralCode: ReferralCode): Promise; + + /** + * 删除用户的指定设备 + */ + removeDevice(userId: string, deviceId: string): Promise; +} + +export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY'); diff --git a/backend/services/identity-service/src/domain/services/index.ts b/backend/services/identity-service/src/domain/services/index.ts new file mode 100644 index 00000000..afdf965f --- /dev/null +++ b/backend/services/identity-service/src/domain/services/index.ts @@ -0,0 +1 @@ +export * from './user-validator.service'; 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 new file mode 100644 index 00000000..e650a27e --- /dev/null +++ b/backend/services/identity-service/src/domain/services/user-validator.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { PhoneNumber } from '../value-objects/phone-number.vo'; +import { ReferralCode } from '../value-objects/referral-code.vo'; +import { ChainType } from '../enums/chain-type.enum'; +import { + IUserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '../repositories/user-account.repository.interface'; + +export interface ValidationResult { + isValid: boolean; + errorMessage?: string; + data?: any; +} + +@Injectable() +export class UserValidatorService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: IUserAccountRepository, + ) {} + + async validateDeviceId(deviceId: string): Promise { + 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 exists = await this.userRepository.existsByPhoneNumber(phoneNumber); + if (exists) { + return { isValid: false, errorMessage: '该手机号已注册' }; + } + + return { isValid: true }; + } + + 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, + }, + }; + } + + async validateWalletAddress( + chainType: ChainType, + address: string, + ): Promise { + const existing = await this.userRepository.findByWalletAddress( + chainType, + address, + ); + + if (existing) { + return { isValid: false, errorMessage: '该地址已被其他账户绑定' }; + } + + return { isValid: true }; + } +} 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 new file mode 100644 index 00000000..4b64f52e --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/account-sequence.vo.ts @@ -0,0 +1,33 @@ +import { DomainException } from '@shared/exceptions/domain.exception'; + +export class AccountSequence { + private readonly _value: number; + + private constructor(value: number) { + if (!Number.isInteger(value) || value <= 0) { + throw new DomainException('账户序列号必须是大于0的整数'); + } + this._value = value; + } + + static create(value: number): AccountSequence { + return new AccountSequence(value); + } + + static next(current: AccountSequence): AccountSequence { + return new AccountSequence(current.value + 1); + } + + get value(): number { + return this._value; + } + + equals(other: AccountSequence): boolean { + if (!other) return false; + return this._value === other.value; + } + + toString(): string { + return String(this._value); + } +} 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 new file mode 100644 index 00000000..5c22292a --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/device-info.vo.ts @@ -0,0 +1,38 @@ +export class DeviceInfo { + private _lastActiveAt: Date; + + constructor( + public readonly deviceId: string, + public readonly deviceName: string, + public readonly addedAt: Date, + lastActiveAt: Date, + ) { + if (!deviceId || deviceId.trim().length === 0) { + throw new Error('设备ID不能为空'); + } + this._lastActiveAt = lastActiveAt; + } + + get lastActiveAt(): Date { + return this._lastActiveAt; + } + + updateActivity(): void { + this._lastActiveAt = new Date(); + } + + isActive(thresholdDays: number = 30): boolean { + const threshold = new Date(); + threshold.setDate(threshold.getDate() - thresholdDays); + return this._lastActiveAt >= threshold; + } + + toJSON(): object { + return { + deviceId: this.deviceId, + deviceName: this.deviceName, + addedAt: this.addedAt.toISOString(), + lastActiveAt: this._lastActiveAt.toISOString(), + }; + } +} diff --git a/backend/services/identity-service/src/domain/value-objects/index.ts b/backend/services/identity-service/src/domain/value-objects/index.ts new file mode 100644 index 00000000..99c5e3ef --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/index.ts @@ -0,0 +1,6 @@ +export * from './account-sequence.vo'; +export * from './phone-number.vo'; +export * from './mnemonic.vo'; +export * from './device-info.vo'; +export * from './referral-code.vo'; +export * from './kyc-info.vo'; 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 new file mode 100644 index 00000000..89b0bbfc --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/kyc-info.vo.ts @@ -0,0 +1,74 @@ +import { DomainException } from '@shared/exceptions/domain.exception'; + +export class KYCInfo { + constructor( + public readonly realName: string, + public readonly idCardNumber: string, + public readonly idCardFrontUrl: string, + public readonly idCardBackUrl: string, + ) { + this.validateRealName(realName); + this.validateIdCardNumber(idCardNumber); + this.validateUrl(idCardFrontUrl, '身份证正面图片'); + this.validateUrl(idCardBackUrl, '身份证反面图片'); + } + + private validateRealName(name: string): void { + if (!name || name.trim().length < 2) { + throw new DomainException('真实姓名至少需要2个字符'); + } + if (name.length > 50) { + throw new DomainException('真实姓名不能超过50个字符'); + } + } + + private validateIdCardNumber(idCard: string): void { + // 18位身份证号校验 + const pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/; + if (!pattern.test(idCard)) { + throw new DomainException('身份证号格式错误'); + } + } + + private validateUrl(url: string, fieldName: string): void { + if (!url || url.trim().length === 0) { + throw new DomainException(`${fieldName}URL不能为空`); + } + // 简单URL格式校验 + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw new DomainException(`${fieldName}URL格式错误`); + } + } + + static create(params: { + realName: string; + idCardNumber: string; + idCardFrontUrl: string; + idCardBackUrl: string; + }): KYCInfo { + return new KYCInfo( + params.realName, + params.idCardNumber, + params.idCardFrontUrl, + params.idCardBackUrl, + ); + } + + maskedIdCardNumber(): string { + return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2'); + } + + maskedRealName(): string { + if (this.realName.length <= 1) { + return '*'; + } + return this.realName[0] + '*'.repeat(this.realName.length - 1); + } + + toJSON(): object { + return { + realName: this.maskedRealName(), + idCardNumber: this.maskedIdCardNumber(), + }; + } +} 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 new file mode 100644 index 00000000..36835916 --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/mnemonic.vo.ts @@ -0,0 +1,49 @@ +import * as bip39 from 'bip39'; +import { DomainException } from '@shared/exceptions/domain.exception'; + +export class Mnemonic { + private readonly _value: string; + + private constructor(value: string) { + const normalized = value.trim().toLowerCase(); + if (!bip39.validateMnemonic(normalized)) { + throw new DomainException('助记词格式错误,必须是有效的BIP39助记词'); + } + this._value = normalized; + } + + static generate(): Mnemonic { + const mnemonic = bip39.generateMnemonic(128); // 12 words + return new Mnemonic(mnemonic); + } + + static create(value: string): Mnemonic { + return new Mnemonic(value); + } + + get value(): string { + return this._value; + } + + toSeed(): Buffer { + return bip39.mnemonicToSeedSync(this._value); + } + + getWords(): string[] { + return this._value.split(' '); + } + + getWordCount(): number { + return this.getWords().length; + } + + equals(other: Mnemonic): boolean { + if (!other) return false; + return this._value === other.value; + } + + // 不暴露原始值的toString + toString(): string { + return '[MNEMONIC HIDDEN]'; + } +} 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 new file mode 100644 index 00000000..6e8fe38e --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/phone-number.vo.ts @@ -0,0 +1,34 @@ +import { DomainException } from '@shared/exceptions/domain.exception'; + +export class PhoneNumber { + private readonly _value: string; + + private constructor(value: string) { + const normalized = value.replace(/\s+/g, ''); + if (!/^1[3-9]\d{9}$/.test(normalized)) { + throw new DomainException('手机号格式错误,必须是11位中国大陆手机号'); + } + this._value = normalized; + } + + static create(value: string): PhoneNumber { + return new PhoneNumber(value); + } + + get value(): string { + return this._value; + } + + masked(): string { + return this._value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); + } + + equals(other: PhoneNumber): boolean { + if (!other) return false; + return this._value === other.value; + } + + toString(): string { + return this.masked(); + } +} 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 new file mode 100644 index 00000000..3494f1f9 --- /dev/null +++ b/backend/services/identity-service/src/domain/value-objects/referral-code.vo.ts @@ -0,0 +1,39 @@ +import { DomainException } from '@shared/exceptions/domain.exception'; + +export class ReferralCode { + private readonly _value: string; + + private constructor(value: string) { + const normalized = value.toUpperCase().trim(); + if (!/^[A-Z0-9]{6}$/.test(normalized)) { + throw new DomainException('推荐码格式错误,必须是6位字母数字组合'); + } + this._value = normalized; + } + + static generate(): ReferralCode { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return new ReferralCode(code); + } + + static create(value: string): ReferralCode { + return new ReferralCode(value); + } + + get value(): string { + return this._value; + } + + equals(other: ReferralCode): boolean { + if (!other) return false; + return this._value === other.value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.ts b/backend/services/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.ts new file mode 100644 index 00000000..ded1fdc3 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.ts @@ -0,0 +1,212 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HDKey } from '@scure/bip32'; +import { + createHash, + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from 'crypto'; +import { bech32 } from 'bech32'; +import { ethers } from 'ethers'; +import { ConfigService } from '@nestjs/config'; +import { Mnemonic } from '@domain/value-objects/mnemonic.vo'; +import { WalletAddress } from '@domain/entities/wallet-address.entity'; +import { ChainType, CHAIN_CONFIG } from '@domain/enums/chain-type.enum'; +import { DomainException } from '@shared/exceptions/domain.exception'; +import { v4 as uuidv4 } from 'uuid'; + +export interface WalletSystemResult { + mnemonic: Mnemonic; + wallets: Map; +} + +export interface EncryptedMnemonicData { + encrypted: string; + authTag: string; + iv: string; +} + +@Injectable() +export class WalletGeneratorService { + private readonly logger = new Logger(WalletGeneratorService.name); + private readonly encryptionSalt: string; + + constructor(private readonly configService: ConfigService) { + this.encryptionSalt = configService.get( + 'WALLET_ENCRYPTION_SALT', + 'rwa-wallet-salt', + ); + } + + generateWalletSystem(params: { + userId: string; + deviceId: string; + }): WalletSystemResult { + const mnemonic = Mnemonic.generate(); + const encryptionKey = this.deriveEncryptionKey( + params.deviceId, + params.userId, + ); + + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const address = this.deriveAddress(chainType, mnemonic); + const encryptedMnemonic = this.encryptMnemonic( + mnemonic.value, + encryptionKey, + ); + + const wallet = WalletAddress.create({ + addressId: `addr_${uuidv4()}`, + userId: params.userId, + chainType, + address, + encryptedMnemonic: JSON.stringify(encryptedMnemonic), + }); + + wallets.set(chainType, wallet); + } + + this.logger.debug(`Generated wallet system for user: ${params.userId}`); + + return { mnemonic, wallets }; + } + + recoverWalletSystem(params: { + userId: string; + mnemonic: Mnemonic; + deviceId: string; + }): Map { + const encryptionKey = this.deriveEncryptionKey( + params.deviceId, + params.userId, + ); + + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const address = this.deriveAddress(chainType, params.mnemonic); + const encryptedMnemonic = this.encryptMnemonic( + params.mnemonic.value, + encryptionKey, + ); + + const wallet = WalletAddress.create({ + addressId: `addr_${uuidv4()}`, + userId: params.userId, + chainType, + address, + encryptedMnemonic: JSON.stringify(encryptedMnemonic), + }); + + wallets.set(chainType, wallet); + } + + this.logger.debug(`Recovered wallet system for user: ${params.userId}`); + + return wallets; + } + + deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string { + const seed = mnemonic.toSeed(); + const config = CHAIN_CONFIG[chainType]; + + switch (chainType) { + case ChainType.KAVA: + case ChainType.DST: + return this.deriveCosmosAddress( + seed, + config.derivationPath, + config.prefix, + ); + + case ChainType.BSC: + return this.deriveEVMAddress(seed, config.derivationPath); + + default: + throw new DomainException(`不支持的链类型: ${chainType}`); + } + } + + verifyMnemonic( + mnemonic: Mnemonic, + chainType: ChainType, + expectedAddress: string, + ): boolean { + const derivedAddress = this.deriveAddress(chainType, mnemonic); + return derivedAddress.toLowerCase() === expectedAddress.toLowerCase(); + } + + private deriveCosmosAddress( + seed: Buffer, + path: string, + prefix: string, + ): string { + const hdkey = HDKey.fromMasterSeed(seed); + const childKey = hdkey.derive(path); + + if (!childKey.publicKey) { + throw new DomainException('无法派生公钥'); + } + + const pubkey = childKey.publicKey; + const hash = createHash('sha256').update(pubkey).digest(); + const addressHash = createHash('ripemd160').update(hash).digest(); + const words = bech32.toWords(addressHash); + + return bech32.encode(prefix, words); + } + + private deriveEVMAddress(seed: Buffer, path: string): string { + const hdkey = HDKey.fromMasterSeed(seed); + const childKey = hdkey.derive(path); + + if (!childKey.privateKey) { + throw new DomainException('无法派生私钥'); + } + + const wallet = new ethers.Wallet(childKey.privateKey); + return wallet.address; + } + + encryptMnemonic(mnemonic: string, key: string): EncryptedMnemonicData { + const derivedKey = scryptSync(key, this.encryptionSalt, 32); + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-gcm', derivedKey, iv); + + let encrypted = cipher.update(mnemonic, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + return { + encrypted, + authTag: authTag.toString('hex'), + iv: iv.toString('hex'), + }; + } + + decryptMnemonic( + encryptedData: EncryptedMnemonicData, + key: string, + ): string { + const derivedKey = scryptSync(key, this.encryptionSalt, 32); + const iv = Buffer.from(encryptedData.iv, 'hex'); + const authTag = Buffer.from(encryptedData.authTag, 'hex'); + const decipher = createDecipheriv('aes-256-gcm', derivedKey, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } + + deriveEncryptionKey(deviceId: string, userId: string): string { + const input = `${deviceId}:${userId}`; + return createHash('sha256').update(input).digest('hex'); + } +} 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 new file mode 100644 index 00000000..245c3de8 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../../redis/redis.service'; +import { ApplicationException } from '@shared/exceptions/application.exception'; + +export enum SmsType { + REGISTER = 'REGISTER', + LOGIN = 'LOGIN', + BIND = 'BIND', + RECOVER = 'RECOVER', +} + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + private readonly codeLength = 6; + private readonly codeTtl = 300; // 5分钟 + private readonly sendInterval = 60; // 发送间隔60秒 + + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) {} + + async sendVerificationCode( + phoneNumber: string, + type: SmsType, + ): Promise<{ success: boolean; message: string }> { + // 检查发送间隔 + const intervalKey = `sms:interval:${type}:${phoneNumber}`; + const hasInterval = await this.redisService.exists(intervalKey); + + if (hasInterval) { + const ttl = await this.redisService.ttl(intervalKey); + throw new ApplicationException(`请${ttl}秒后再试`); + } + + // 生成验证码 + const code = this.generateCode(); + + // 发送短信(调用第三方服务) + try { + await this.sendSms(phoneNumber, code, type); + } catch (error) { + this.logger.error(`Failed to send SMS to ${phoneNumber}`, error); + throw new ApplicationException('短信发送失败,请稍后重试'); + } + + // 保存验证码到Redis + await this.redisService.setSmsCode(phoneNumber, type, code, this.codeTtl); + + // 设置发送间隔 + await this.redisService.set(intervalKey, '1', this.sendInterval); + + this.logger.log(`SMS code sent to ${phoneNumber} for ${type}`); + + return { success: true, message: '验证码已发送' }; + } + + async verifyCode( + phoneNumber: string, + type: SmsType, + code: string, + ): Promise { + const storedCode = await this.redisService.getSmsCode(phoneNumber, type); + + if (!storedCode) { + throw new ApplicationException('验证码已过期,请重新获取'); + } + + if (storedCode !== code) { + throw new ApplicationException('验证码错误'); + } + + // 验证成功后删除验证码 + await this.redisService.deleteSmsCode(phoneNumber, type); + + this.logger.log(`SMS code verified for ${phoneNumber}`); + + return true; + } + + private generateCode(): string { + let code = ''; + for (let i = 0; i < this.codeLength; i++) { + code += Math.floor(Math.random() * 10).toString(); + } + return code; + } + + private async sendSms( + phoneNumber: string, + code: string, + type: SmsType, + ): Promise { + 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}`); + } +} diff --git a/backend/services/identity-service/src/infrastructure/kafka/dead-letter.service.ts b/backend/services/identity-service/src/infrastructure/kafka/dead-letter.service.ts new file mode 100644 index 00000000..d24567d9 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/dead-letter.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../persistence/prisma/prisma.service'; +import { DomainEventMessage } from './event-publisher.service'; + +@Injectable() +export class DeadLetterService { + private readonly logger = new Logger(DeadLetterService.name); + + constructor(private readonly prisma: PrismaService) {} + + async saveFailedEvent( + topic: string, + message: DomainEventMessage, + error: Error, + retryCount: number, + ): Promise { + await this.prisma.deadLetterEvent.create({ + data: { + topic, + eventId: message.eventId, + eventType: message.eventType, + aggregateId: message.aggregateId, + aggregateType: message.aggregateType, + payload: message.payload, + errorMessage: error.message, + errorStack: error.stack, + retryCount, + createdAt: new Date(), + }, + }); + + this.logger.warn(`Event saved to dead letter queue: ${message.eventId}`); + } + + async getFailedEvents(limit: number = 100): Promise { + return this.prisma.deadLetterEvent.findMany({ + where: { processedAt: null }, + orderBy: { createdAt: 'asc' }, + take: limit, + }); + } + + async markAsProcessed(id: bigint): Promise { + await this.prisma.deadLetterEvent.update({ + where: { id }, + data: { processedAt: new Date() }, + }); + + this.logger.log(`Dead letter event marked as processed: ${id}`); + } + + async incrementRetryCount(id: bigint): Promise { + await this.prisma.deadLetterEvent.update({ + where: { id }, + data: { retryCount: { increment: 1 } }, + }); + } + + async getStatistics(): Promise<{ + total: number; + pending: number; + processed: number; + byTopic: Record; + }> { + const [total, pending, processed, byTopic] = await Promise.all([ + this.prisma.deadLetterEvent.count(), + this.prisma.deadLetterEvent.count({ where: { processedAt: null } }), + this.prisma.deadLetterEvent.count({ where: { processedAt: { not: null } } }), + this.prisma.deadLetterEvent.groupBy({ + by: ['topic'], + _count: true, + where: { processedAt: null }, + }), + ]); + + const topicStats: Record = {}; + for (const item of byTopic) { + topicStats[item.topic] = item._count; + } + + return { total, pending, processed, byTopic: topicStats }; + } +} diff --git a/backend/services/identity-service/src/infrastructure/kafka/event-consumer.controller.ts b/backend/services/identity-service/src/infrastructure/kafka/event-consumer.controller.ts new file mode 100644 index 00000000..e9006e3e --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/event-consumer.controller.ts @@ -0,0 +1,237 @@ +import { Controller, Logger } from '@nestjs/common'; +import { + MessagePattern, + Payload, + Ctx, + KafkaContext, +} from '@nestjs/microservices'; +import { IDENTITY_TOPICS, DomainEventMessage } from './event-publisher.service'; + +@Controller() +export class EventConsumerController { + private readonly logger = new Logger(EventConsumerController.name); + + @MessagePattern(IDENTITY_TOPICS.USER_ACCOUNT_CREATED) + async handleUserAccountCreated( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + const { offset } = context.getMessage(); + const partition = context.getPartition(); + + this.logger.log( + `Received UserAccountCreated event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, + ); + + try { + await this.processUserAccountCreated(message.payload); + this.logger.log( + `Successfully processed UserAccountCreated: ${message.eventId}`, + ); + } catch (error) { + this.logger.error( + `Failed to process UserAccountCreated: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.DEVICE_ADDED) + async handleDeviceAdded( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + const { offset } = context.getMessage(); + const partition = context.getPartition(); + + this.logger.log( + `Received DeviceAdded event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, + ); + + try { + await this.processDeviceAdded(message.payload); + this.logger.log(`Successfully processed DeviceAdded: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process DeviceAdded: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.PHONE_BOUND) + async handlePhoneBound( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + const { offset } = context.getMessage(); + const partition = context.getPartition(); + + this.logger.log( + `Received PhoneBound event: ${message.eventId}, partition: ${partition}, offset: ${offset}`, + ); + + try { + await this.processPhoneBound(message.payload); + this.logger.log(`Successfully processed PhoneBound: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process PhoneBound: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.KYC_SUBMITTED) + async handleKYCSubmitted( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + this.logger.log(`Received KYCSubmitted event: ${message.eventId}`); + + try { + await this.processKYCSubmitted(message.payload); + this.logger.log(`Successfully processed KYCSubmitted: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process KYCSubmitted: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.KYC_APPROVED) + async handleKYCApproved( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + this.logger.log(`Received KYCApproved event: ${message.eventId}`); + + try { + await this.processKYCApproved(message.payload); + this.logger.log(`Successfully processed KYCApproved: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process KYCApproved: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.KYC_REJECTED) + async handleKYCRejected( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + this.logger.log(`Received KYCRejected event: ${message.eventId}`); + + try { + await this.processKYCRejected(message.payload); + this.logger.log(`Successfully processed KYCRejected: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process KYCRejected: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.ACCOUNT_FROZEN) + async handleAccountFrozen( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + this.logger.log(`Received AccountFrozen event: ${message.eventId}`); + + try { + await this.processAccountFrozen(message.payload); + this.logger.log( + `Successfully processed AccountFrozen: ${message.eventId}`, + ); + } catch (error) { + this.logger.error( + `Failed to process AccountFrozen: ${message.eventId}`, + error, + ); + throw error; + } + } + + @MessagePattern(IDENTITY_TOPICS.WALLET_BOUND) + async handleWalletBound( + @Payload() message: DomainEventMessage, + @Ctx() context: KafkaContext, + ): Promise { + this.logger.log(`Received WalletBound event: ${message.eventId}`); + + try { + await this.processWalletBound(message.payload); + this.logger.log(`Successfully processed WalletBound: ${message.eventId}`); + } catch (error) { + this.logger.error( + `Failed to process WalletBound: ${message.eventId}`, + error, + ); + throw error; + } + } + + // 业务处理方法 + private async processUserAccountCreated(payload: any): Promise { + this.logger.debug( + `Processing UserAccountCreated: userId=${payload.userId}`, + ); + // 发送欢迎通知 + // 初始化用户积分 + // 记录邀请关系 + } + + private async processDeviceAdded(payload: any): Promise { + this.logger.debug( + `Processing DeviceAdded: userId=${payload.userId}, deviceId=${payload.deviceId}`, + ); + // 发送新设备登录通知 + // 安全审计记录 + } + + private async processPhoneBound(payload: any): Promise { + this.logger.debug(`Processing PhoneBound: userId=${payload.userId}`); + // 发送绑定成功短信 + } + + private async processKYCSubmitted(payload: any): Promise { + this.logger.debug(`Processing KYCSubmitted: userId=${payload.userId}`); + // 触发KYC审核流程 + // 通知审核人员 + } + + private async processKYCApproved(payload: any): Promise { + this.logger.debug(`Processing KYCApproved: userId=${payload.userId}`); + // 发送审核通过通知 + // 解锁高级功能 + } + + private async processKYCRejected(payload: any): Promise { + this.logger.debug(`Processing KYCRejected: userId=${payload.userId}`); + // 发送审核失败通知 + } + + private async processAccountFrozen(payload: any): Promise { + this.logger.debug(`Processing AccountFrozen: userId=${payload.userId}`); + // 发送账户冻结通知 + // 清除用户会话 + } + + private async processWalletBound(payload: any): Promise { + this.logger.debug( + `Processing WalletBound: userId=${payload.userId}, chain=${payload.chainType}`, + ); + // 同步钱包余额 + } +} 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 new file mode 100644 index 00000000..3613ac64 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/event-publisher.service.ts @@ -0,0 +1,241 @@ +import { + Injectable, + Inject, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; +import { ClientKafka } from '@nestjs/microservices'; +import { v4 as uuidv4 } from 'uuid'; +import { KAFKA_SERVICE } from './kafka.module'; + +export interface DomainEventMessage { + eventId: string; + eventType: string; + aggregateId: string; + aggregateType: string; + payload: any; + occurredAt: string; + version: number; +} + +export const IDENTITY_TOPICS = { + USER_ACCOUNT_CREATED: 'identity.user-account.created', + DEVICE_ADDED: 'identity.device.added', + DEVICE_REMOVED: 'identity.device.removed', + PHONE_BOUND: 'identity.phone.bound', + KYC_SUBMITTED: 'identity.kyc.submitted', + KYC_APPROVED: 'identity.kyc.approved', + KYC_REJECTED: 'identity.kyc.rejected', + ACCOUNT_FROZEN: 'identity.account.frozen', + ACCOUNT_UNFROZEN: 'identity.account.unfrozen', + ACCOUNT_DEACTIVATED: 'identity.account.deactivated', + WALLET_BOUND: 'identity.wallet.bound', +}; + +@Injectable() +export class EventPublisherService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(EventPublisherService.name); + private isConnected = false; + + constructor( + @Inject(KAFKA_SERVICE) + private readonly kafkaClient: ClientKafka, + ) {} + + async onModuleInit(): Promise { + try { + await this.kafkaClient.connect(); + this.isConnected = true; + this.logger.log('Kafka producer connected'); + } catch (error) { + this.logger.error('Failed to connect Kafka producer', error); + // 不抛出错误,允许服务在没有Kafka的情况下启动 + this.isConnected = false; + } + } + + async onModuleDestroy(): Promise { + 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, + }; + } +} diff --git a/backend/services/identity-service/src/infrastructure/kafka/event-retry.service.ts b/backend/services/identity-service/src/infrastructure/kafka/event-retry.service.ts new file mode 100644 index 00000000..49abc93d --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/event-retry.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { EventPublisherService } from './event-publisher.service'; +import { DeadLetterService } from './dead-letter.service'; + +@Injectable() +export class EventRetryService { + private readonly logger = new Logger(EventRetryService.name); + private readonly maxRetries = 3; + private isRunning = false; + + constructor( + private readonly eventPublisher: EventPublisherService, + private readonly deadLetterService: DeadLetterService, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async retryFailedEvents(): Promise { + if (this.isRunning) { + this.logger.debug('Retry job already running, skipping'); + return; + } + + this.isRunning = true; + this.logger.log('Starting failed events retry job'); + + try { + const failedEvents = await this.deadLetterService.getFailedEvents(50); + let successCount = 0; + let failCount = 0; + + for (const event of failedEvents) { + if (event.retryCount >= this.maxRetries) { + this.logger.warn( + `Event ${event.eventId} exceeded max retries (${this.maxRetries}), skipping`, + ); + continue; + } + + try { + await this.eventPublisher.publish(event.topic, { + aggregateId: event.aggregateId, + aggregateType: event.aggregateType, + eventType: event.eventType, + payload: event.payload, + }); + + await this.deadLetterService.markAsProcessed(event.id); + successCount++; + this.logger.log(`Successfully retried event: ${event.eventId}`); + } catch (error) { + failCount++; + await this.deadLetterService.incrementRetryCount(event.id); + this.logger.error(`Failed to retry event: ${event.eventId}`, error); + } + } + + this.logger.log( + `Finished retry job: ${successCount} succeeded, ${failCount} failed`, + ); + } finally { + this.isRunning = false; + } + } + + async manualRetry(eventId: string): Promise { + const events = await this.deadLetterService.getFailedEvents(1000); + const event = events.find((e) => e.eventId === eventId); + + if (!event) { + this.logger.warn(`Event not found: ${eventId}`); + return false; + } + + try { + await this.eventPublisher.publish(event.topic, { + aggregateId: event.aggregateId, + aggregateType: event.aggregateType, + eventType: event.eventType, + payload: event.payload, + }); + + await this.deadLetterService.markAsProcessed(event.id); + this.logger.log(`Manually retried event: ${eventId}`); + return true; + } catch (error) { + this.logger.error(`Failed to manually retry event: ${eventId}`, error); + return false; + } + } +} diff --git a/backend/services/identity-service/src/infrastructure/kafka/index.ts b/backend/services/identity-service/src/infrastructure/kafka/index.ts new file mode 100644 index 00000000..2ee247ad --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/index.ts @@ -0,0 +1,5 @@ +export * from './kafka.module'; +export * from './event-publisher.service'; +export * from './event-consumer.controller'; +export * from './dead-letter.service'; +export * from './event-retry.service'; diff --git a/backend/services/identity-service/src/infrastructure/kafka/kafka.module.ts b/backend/services/identity-service/src/infrastructure/kafka/kafka.module.ts new file mode 100644 index 00000000..55e37301 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/kafka/kafka.module.ts @@ -0,0 +1,46 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { EventPublisherService } from './event-publisher.service'; +import { EventConsumerController } from './event-consumer.controller'; +import { DeadLetterService } from './dead-letter.service'; +import { EventRetryService } from './event-retry.service'; + +export const KAFKA_SERVICE = 'KAFKA_SERVICE'; + +@Global() +@Module({ + imports: [ + ClientsModule.registerAsync([ + { + name: KAFKA_SERVICE, + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + transport: Transport.KAFKA, + options: { + client: { + clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'), + brokers: configService + .get('KAFKA_BROKERS', 'localhost:9092') + .split(','), + }, + consumer: { + groupId: configService.get( + 'KAFKA_GROUP_ID', + 'identity-service-group', + ), + }, + producer: { + allowAutoTopicCreation: true, + }, + }, + }), + inject: [ConfigService], + }, + ]), + ], + controllers: [EventConsumerController], + providers: [EventPublisherService, DeadLetterService, EventRetryService], + exports: [EventPublisherService], +}) +export class KafkaModule {} 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 new file mode 100644 index 00000000..502cdd17 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/mappers/user-account.mapper.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; +import { + UserAccount as PrismaUserAccount, + UserDevice as PrismaUserDevice, + WalletAddress as PrismaWalletAddress, +} from '@prisma/client'; +import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate'; +import { AccountSequence } from '@domain/value-objects/account-sequence.vo'; +import { PhoneNumber } from '@domain/value-objects/phone-number.vo'; +import { ReferralCode } from '@domain/value-objects/referral-code.vo'; +import { DeviceInfo } from '@domain/value-objects/device-info.vo'; +import { KYCInfo } from '@domain/value-objects/kyc-info.vo'; +import { WalletAddress } from '@domain/entities/wallet-address.entity'; +import { ChainType } from '@domain/enums/chain-type.enum'; +import { KYCStatus } from '@domain/enums/kyc-status.enum'; +import { AccountStatus } from '@domain/enums/account-status.enum'; + +export type UserAccountWithRelations = PrismaUserAccount & { + devices: PrismaUserDevice[]; + walletAddresses: PrismaWalletAddress[]; +}; + +@Injectable() +export class UserAccountMapper { + toDomain(raw: UserAccountWithRelations): UserAccount { + // Map devices + const devices = new Map(); + for (const device of raw.devices) { + devices.set( + device.deviceId, + new DeviceInfo( + device.deviceId, + device.deviceName || '未命名设备', + device.addedAt, + device.lastActiveAt, + ), + ); + } + + // Map wallet addresses + const walletAddresses = new Map(); + for (const wallet of raw.walletAddresses) { + walletAddresses.set( + wallet.chainType as ChainType, + WalletAddress.create({ + addressId: String(wallet.id), + userId: String(wallet.userId), + chainType: wallet.chainType as ChainType, + address: wallet.address, + encryptedMnemonic: wallet.encryptedMnemonic || '', + status: wallet.status as 'ACTIVE' | 'DISABLED', + boundAt: wallet.boundAt, + }), + ); + } + + // Map KYC info + const kycInfo = + raw.realName && raw.idCardNumber && raw.idCardFrontUrl && raw.idCardBackUrl + ? KYCInfo.create({ + realName: raw.realName, + idCardNumber: raw.idCardNumber, + idCardFrontUrl: raw.idCardFrontUrl, + idCardBackUrl: raw.idCardBackUrl, + }) + : null; + + return UserAccount.fromPersistence({ + userId: String(raw.id), + accountSequence: AccountSequence.create(Number(raw.accountSequence)), + devices, + phoneNumber: raw.phoneNumber ? PhoneNumber.create(raw.phoneNumber) : null, + nickname: raw.nickname, + avatarUrl: raw.avatarUrl, + inviterSequence: raw.inviterSequence + ? AccountSequence.create(Number(raw.inviterSequence)) + : null, + referralCode: ReferralCode.create(raw.referralCode), + provinceCode: raw.provinceCode, + cityCode: raw.cityCode, + address: raw.address, + walletAddresses, + kycInfo, + kycStatus: raw.kycStatus as KYCStatus, + status: raw.status as AccountStatus, + registeredAt: raw.registeredAt, + lastLoginAt: raw.lastLoginAt, + updatedAt: raw.updatedAt, + }); + } + + toPersistence(account: UserAccount): any { + const data = account.toPersistenceData() as any; + + return { + id: BigInt(data.userId), + accountSequence: BigInt(data.accountSequence), + phoneNumber: data.phoneNumber, + nickname: data.nickname, + avatarUrl: data.avatarUrl, + inviterSequence: data.inviterSequence + ? BigInt(data.inviterSequence) + : null, + referralCode: data.referralCode, + provinceCode: data.provinceCode, + cityCode: data.cityCode, + address: data.address, + kycStatus: data.kycStatus, + realName: data.realName, + idCardNumber: data.idCardNumber, + idCardFrontUrl: data.idCardFrontUrl, + idCardBackUrl: data.idCardBackUrl, + status: data.status, + lastLoginAt: data.lastLoginAt, + updatedAt: data.updatedAt, + }; + } + + toDevicePersistence( + userId: string, + device: DeviceInfo, + ): any { + return { + userId: BigInt(userId), + deviceId: device.deviceId, + deviceName: device.deviceName, + addedAt: device.addedAt, + lastActiveAt: device.lastActiveAt, + }; + } + + toWalletPersistence(wallet: WalletAddress): any { + return { + userId: BigInt(wallet.userId), + chainType: wallet.chainType, + address: wallet.address, + encryptedMnemonic: wallet.encryptedMnemonic, + status: wallet.status, + boundAt: wallet.boundAt, + }; + } +} 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 new file mode 100644 index 00000000..878afd2a --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/prisma/prisma.service.ts @@ -0,0 +1,54 @@ +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + log: [ + { emit: 'event', level: 'query' }, + { emit: 'stdout', level: 'info' }, + { emit: 'stdout', level: 'warn' }, + { emit: 'stdout', level: 'error' }, + ], + }); + } + + async onModuleInit(): Promise { + await this.$connect(); + this.logger.log('Connected to database'); + } + + async onModuleDestroy(): Promise { + 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 new file mode 100644 index 00000000..69358a37 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts @@ -0,0 +1,239 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IUserAccountRepository } from '@domain/repositories/user-account.repository.interface'; +import { UserAccount } from '@domain/aggregates/user-account/user-account.aggregate'; +import { AccountSequence } from '@domain/value-objects/account-sequence.vo'; +import { PhoneNumber } from '@domain/value-objects/phone-number.vo'; +import { ReferralCode } from '@domain/value-objects/referral-code.vo'; +import { ChainType } from '@domain/enums/chain-type.enum'; +import { WalletAddress } from '@domain/entities/wallet-address.entity'; +import { PrismaService } from '../prisma/prisma.service'; +import { UserAccountMapper } from '../mappers/user-account.mapper'; + +@Injectable() +export class UserAccountRepositoryImpl implements IUserAccountRepository { + private readonly logger = new Logger(UserAccountRepositoryImpl.name); + + constructor( + private readonly prisma: PrismaService, + private readonly mapper: UserAccountMapper, + ) {} + + async save(account: UserAccount): Promise { + const data = this.mapper.toPersistence(account); + const devices = account.getAllDevices(); + + await this.prisma.$transaction(async (tx) => { + // Upsert user account + await tx.userAccount.upsert({ + where: { id: data.id }, + create: { + ...data, + registeredAt: new Date(), + }, + update: { + phoneNumber: data.phoneNumber, + nickname: data.nickname, + avatarUrl: data.avatarUrl, + address: data.address, + kycStatus: data.kycStatus, + realName: data.realName, + idCardNumber: data.idCardNumber, + idCardFrontUrl: data.idCardFrontUrl, + idCardBackUrl: data.idCardBackUrl, + status: data.status, + lastLoginAt: data.lastLoginAt, + updatedAt: data.updatedAt, + }, + }); + + // Upsert devices + for (const device of devices) { + const deviceData = this.mapper.toDevicePersistence(account.userId, device); + await tx.userDevice.upsert({ + where: { + userId_deviceId: { + userId: deviceData.userId, + deviceId: deviceData.deviceId, + }, + }, + create: deviceData, + update: { + deviceName: deviceData.deviceName, + lastActiveAt: deviceData.lastActiveAt, + }, + }); + } + }); + + this.logger.debug(`Saved user account: ${account.userId}`); + } + + async saveWallets(userId: string, wallets: WalletAddress[]): Promise { + 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, + }, + }); + + return raw ? this.mapper.toDomain(raw) : null; + } + + async findByAccountSequence( + sequence: AccountSequence, + ): Promise { + const raw = await this.prisma.userAccount.findUnique({ + where: { accountSequence: BigInt(sequence.value) }, + include: { + devices: true, + walletAddresses: true, + }, + }); + + return raw ? this.mapper.toDomain(raw) : null; + } + + async findByDeviceId(deviceId: string): Promise { + const device = await this.prisma.userDevice.findFirst({ + where: { deviceId }, + include: { + user: { + include: { + devices: true, + walletAddresses: true, + }, + }, + }, + }); + + return device ? this.mapper.toDomain(device.user) : null; + } + + async findByPhoneNumber(phoneNumber: PhoneNumber): Promise { + const raw = await this.prisma.userAccount.findUnique({ + where: { phoneNumber: phoneNumber.value }, + include: { + devices: true, + walletAddresses: true, + }, + }); + + return raw ? this.mapper.toDomain(raw) : null; + } + + async findByReferralCode( + referralCode: ReferralCode, + ): Promise { + const raw = await this.prisma.userAccount.findUnique({ + where: { referralCode: referralCode.value }, + include: { + devices: true, + walletAddresses: true, + }, + }); + + return raw ? this.mapper.toDomain(raw) : null; + } + + async findByWalletAddress( + chainType: ChainType, + address: string, + ): Promise { + const wallet = await this.prisma.walletAddress.findUnique({ + where: { + chainType_address: { + chainType, + address, + }, + }, + include: { + user: { + include: { + devices: true, + walletAddresses: true, + }, + }, + }, + }); + + return wallet ? this.mapper.toDomain(wallet.user) : null; + } + + async getNextAccountSequence(): Promise { + // 使用行级锁获取下一个序列号 + const result = await this.prisma.$queryRaw<{ current_sequence: bigint }[]>` + SELECT current_sequence FROM account_sequence_generator + WHERE id = 1 + FOR UPDATE + `; + + let nextSequence: number; + + if (result.length === 0) { + // 初始化序列号生成器 + await this.prisma.$executeRaw` + INSERT INTO account_sequence_generator (id, current_sequence) + VALUES (1, 1) + ON CONFLICT (id) DO UPDATE SET current_sequence = account_sequence_generator.current_sequence + 1 + `; + nextSequence = 1; + } else { + // 更新并获取下一个序列号 + await this.prisma.$executeRaw` + UPDATE account_sequence_generator + SET current_sequence = current_sequence + 1, updated_at = NOW() + WHERE id = 1 + `; + nextSequence = Number(result[0].current_sequence) + 1; + } + + return AccountSequence.create(nextSequence); + } + + async existsByDeviceId(deviceId: string): Promise { + 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, + }, + }, + }); + + this.logger.debug(`Removed device ${deviceId} from user ${userId}`); + } +} diff --git a/backend/services/identity-service/src/infrastructure/redis/redis.module.ts b/backend/services/identity-service/src/infrastructure/redis/redis.module.ts new file mode 100644 index 00000000..eaeebd60 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,33 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { RedisService } from './redis.service'; + +export const REDIS_CLIENT = Symbol('REDIS_CLIENT'); + +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: REDIS_CLIENT, + useFactory: (configService: ConfigService) => { + return new Redis({ + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD') || undefined, + db: configService.get('REDIS_DB', 0), + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + }); + }, + inject: [ConfigService], + }, + RedisService, + ], + exports: [RedisService, REDIS_CLIENT], +}) +export class RedisModule {} diff --git a/backend/services/identity-service/src/infrastructure/redis/redis.service.ts b/backend/services/identity-service/src/infrastructure/redis/redis.service.ts new file mode 100644 index 00000000..21bde8a3 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/redis/redis.service.ts @@ -0,0 +1,135 @@ +import { Injectable, Inject, Logger, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS_CLIENT } from './redis.module'; + +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + + constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) { + this.client.on('connect', () => { + this.logger.log('Redis connected'); + }); + + this.client.on('error', (error) => { + this.logger.error('Redis error', error); + }); + } + + async onModuleDestroy(): Promise { + await this.client.quit(); + this.logger.log('Redis disconnected'); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.setex(key, ttlSeconds, value); + } 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); + } + + async exists(key: string): Promise { + const result = await this.client.exists(key); + return result === 1; + } + + async setJSON(key: string, value: T, ttlSeconds?: number): Promise { + await this.set(key, JSON.stringify(value), ttlSeconds); + } + + 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 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); + } +} diff --git a/backend/services/identity-service/src/main.ts b/backend/services/identity-service/src/main.ts new file mode 100644 index 00000000..07c76a60 --- /dev/null +++ b/backend/services/identity-service/src/main.ts @@ -0,0 +1,88 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; +import { GlobalExceptionFilter } from './shared/filters/global-exception.filter'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + + // 全局前缀 + const apiPrefix = configService.get('API_PREFIX', 'api/v1'); + app.setGlobalPrefix(apiPrefix); + + // CORS + app.enableCors({ + origin: true, + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + credentials: true, + }); + + // 全局验证管道 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // 全局异常过滤器 + app.useGlobalFilters(new GlobalExceptionFilter()); + + // Swagger文档 + if (configService.get('NODE_ENV') !== 'production') { + const config = new DocumentBuilder() + .setTitle('Identity Service API') + .setDescription('RWA Identity & User Context Microservice') + .setVersion('2.0.0') + .addBearerAuth() + .addTag('用户管理', '用户账户相关接口') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + logger.log('Swagger documentation available at /api/docs'); + } + + // 连接Kafka微服务 + const kafkaBrokers = configService.get('KAFKA_BROKERS', 'localhost:9092'); + try { + app.connectMicroservice({ + transport: Transport.KAFKA, + options: { + client: { + clientId: configService.get('KAFKA_CLIENT_ID', 'identity-service'), + brokers: kafkaBrokers.split(','), + }, + consumer: { + groupId: configService.get('KAFKA_GROUP_ID', 'identity-service-group'), + }, + }, + }); + + // 启动所有微服务 + await app.startAllMicroservices(); + logger.log('Kafka microservice connected'); + } catch (error) { + logger.warn(`Kafka connection failed: ${error.message}. Service will continue without Kafka.`); + } + + // 启动HTTP服务 + const port = configService.get('PORT', 3000); + await app.listen(port); + + logger.log(`🚀 Identity Service is running on: http://localhost:${port}`); + logger.log(`📚 API Prefix: ${apiPrefix}`); + logger.log(`🌍 Environment: ${configService.get('NODE_ENV', 'development')}`); +} + +bootstrap(); diff --git a/backend/services/identity-service/src/shared/decorators/index.ts b/backend/services/identity-service/src/shared/decorators/index.ts new file mode 100644 index 00000000..9e645d47 --- /dev/null +++ b/backend/services/identity-service/src/shared/decorators/index.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from '../guards/jwt-auth.guard'; + +export const CurrentUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); + +export const CurrentDeviceId = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user?.deviceId; + }, +); + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/services/identity-service/src/shared/exceptions/application.exception.ts b/backend/services/identity-service/src/shared/exceptions/application.exception.ts new file mode 100644 index 00000000..8fce0b79 --- /dev/null +++ b/backend/services/identity-service/src/shared/exceptions/application.exception.ts @@ -0,0 +1,7 @@ +export class ApplicationException extends Error { + constructor(message: string) { + super(message); + this.name = 'ApplicationException'; + Object.setPrototypeOf(this, ApplicationException.prototype); + } +} diff --git a/backend/services/identity-service/src/shared/exceptions/domain.exception.ts b/backend/services/identity-service/src/shared/exceptions/domain.exception.ts new file mode 100644 index 00000000..e107243c --- /dev/null +++ b/backend/services/identity-service/src/shared/exceptions/domain.exception.ts @@ -0,0 +1,7 @@ +export class DomainException extends Error { + constructor(message: string) { + super(message); + this.name = 'DomainException'; + Object.setPrototypeOf(this, DomainException.prototype); + } +} diff --git a/backend/services/identity-service/src/shared/exceptions/index.ts b/backend/services/identity-service/src/shared/exceptions/index.ts new file mode 100644 index 00000000..a9405a8a --- /dev/null +++ b/backend/services/identity-service/src/shared/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from './domain.exception'; +export * from './application.exception'; 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 new file mode 100644 index 00000000..fba0df39 --- /dev/null +++ b/backend/services/identity-service/src/shared/filters/global-exception.filter.ts @@ -0,0 +1,67 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, + HttpException, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; +import { DomainException } from '../exceptions/domain.exception'; +import { ApplicationException } from '../exceptions/application.exception'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status: number; + let message: string; + let error: string; + + if (exception instanceof DomainException) { + status = HttpStatus.BAD_REQUEST; + message = exception.message; + error = 'DomainException'; + } else if (exception instanceof ApplicationException) { + status = HttpStatus.BAD_REQUEST; + message = exception.message; + error = 'ApplicationException'; + } else if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + message = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as any).message || exception.message; + error = exception.name; + } else if (exception instanceof Error) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = '服务器内部错误'; + error = 'InternalServerError'; + + // 记录未知错误 + this.logger.error( + `Unhandled exception: ${exception.message}`, + exception.stack, + ); + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = '未知错误'; + error = 'UnknownError'; + } + + response.status(status).json({ + success: false, + statusCode: status, + message, + error, + path: request.url, + timestamp: new Date().toISOString(), + }); + } +} 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 new file mode 100644 index 00000000..5152346b --- /dev/null +++ b/backend/services/identity-service/src/shared/guards/jwt-auth.guard.ts @@ -0,0 +1,36 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('未授权访问'); + } + return user; + } +} diff --git a/backend/services/identity-service/src/shared/strategies/jwt.strategy.ts b/backend/services/identity-service/src/shared/strategies/jwt.strategy.ts new file mode 100644 index 00000000..b4f50fd6 --- /dev/null +++ b/backend/services/identity-service/src/shared/strategies/jwt.strategy.ts @@ -0,0 +1,36 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +export interface JwtPayload { + userId: string; + accountSequence: number; + deviceId: string; + type: 'access' | 'refresh'; + iat: number; + exp: number; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET', 'default-secret'), + }); + } + + async validate(payload: JwtPayload) { + if (payload.type !== 'access') { + throw new UnauthorizedException('无效的Token类型'); + } + + return { + userId: payload.userId, + accountSequence: payload.accountSequence, + deviceId: payload.deviceId, + }; + } +} diff --git a/backend/services/identity-service/test/jest-e2e.json b/backend/services/identity-service/test/jest-e2e.json new file mode 100644 index 00000000..c5c30336 --- /dev/null +++ b/backend/services/identity-service/test/jest-e2e.json @@ -0,0 +1,17 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@app/(.*)$": "/src/$1", + "^@domain/(.*)$": "/src/domain/$1", + "^@application/(.*)$": "/src/application/$1", + "^@infrastructure/(.*)$": "/src/infrastructure/$1", + "^@api/(.*)$": "/src/api/$1", + "^@shared/(.*)$": "/src/shared/$1" + } +} diff --git a/backend/services/identity-service/tsconfig.build.json b/backend/services/identity-service/tsconfig.build.json new file mode 100644 index 00000000..64f86c6b --- /dev/null +++ b/backend/services/identity-service/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/services/identity-service/tsconfig.json b/backend/services/identity-service/tsconfig.json index e69de29b..2fcc9ce3 100644 --- a/backend/services/identity-service/tsconfig.json +++ b/backend/services/identity-service/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "@app/*": ["src/*"], + "@domain/*": ["src/domain/*"], + "@application/*": ["src/application/*"], + "@infrastructure/*": ["src/infrastructure/*"], + "@api/*": ["src/api/*"], + "@shared/*": ["src/shared/*"], + "@config/*": ["src/config/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +}