diff --git a/backend/services/identity-service/identity-service/.env.development b/backend/services/identity-service/identity-service/.env.development new file mode 100644 index 00000000..e00b0d6b --- /dev/null +++ b/backend/services/identity-service/identity-service/.env.development @@ -0,0 +1,29 @@ +# Database +DATABASE_URL="postgresql://postgres:password@localhost:5432/rwa_identity?schema=public" + +# JWT +JWT_SECRET="dev-jwt-secret-key" +JWT_ACCESS_EXPIRES_IN="2h" +JWT_REFRESH_EXPIRES_IN="30d" + +# Redis +REDIS_HOST="localhost" +REDIS_PORT=6379 +REDIS_PASSWORD="" +REDIS_DB=0 + +# Kafka +KAFKA_BROKERS="localhost:9092" +KAFKA_CLIENT_ID="identity-service" +KAFKA_GROUP_ID="identity-service-group" + +# SMS Service +SMS_API_URL="https://sms-api.example.com" +SMS_API_KEY="dev-sms-api-key" + +# App +APP_PORT=3000 +APP_ENV="development" + +# Blockchain Encryption +WALLET_ENCRYPTION_SALT="dev-wallet-salt" diff --git a/backend/services/identity-service/identity-service/.env.example b/backend/services/identity-service/identity-service/.env.example new file mode 100644 index 00000000..cc9c2aa5 --- /dev/null +++ b/backend/services/identity-service/identity-service/.env.example @@ -0,0 +1,29 @@ +# Database +DATABASE_URL="postgresql://postgres:password@localhost:5432/rwa_identity?schema=public" + +# JWT +JWT_SECRET="your-super-secret-jwt-key-change-in-production" +JWT_ACCESS_EXPIRES_IN="2h" +JWT_REFRESH_EXPIRES_IN="30d" + +# Redis +REDIS_HOST="localhost" +REDIS_PORT=6379 +REDIS_PASSWORD="" +REDIS_DB=0 + +# Kafka +KAFKA_BROKERS="localhost:9092" +KAFKA_CLIENT_ID="identity-service" +KAFKA_GROUP_ID="identity-service-group" + +# SMS Service +SMS_API_URL="https://sms-api.example.com" +SMS_API_KEY="your-sms-api-key" + +# App +APP_PORT=3000 +APP_ENV="development" + +# Blockchain Encryption +WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production" diff --git a/backend/services/identity-service/identity-service/.env.production b/backend/services/identity-service/identity-service/.env.production new file mode 100644 index 00000000..170f9bcb --- /dev/null +++ b/backend/services/identity-service/identity-service/.env.production @@ -0,0 +1,29 @@ +# Database +DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}?schema=public" + +# JWT +JWT_SECRET="${JWT_SECRET}" +JWT_ACCESS_EXPIRES_IN="2h" +JWT_REFRESH_EXPIRES_IN="30d" + +# Redis +REDIS_HOST="${REDIS_HOST}" +REDIS_PORT=6379 +REDIS_PASSWORD="${REDIS_PASSWORD}" +REDIS_DB=0 + +# Kafka +KAFKA_BROKERS="${KAFKA_BROKERS}" +KAFKA_CLIENT_ID="identity-service" +KAFKA_GROUP_ID="identity-service-group" + +# SMS Service +SMS_API_URL="${SMS_API_URL}" +SMS_API_KEY="${SMS_API_KEY}" + +# App +APP_PORT=3000 +APP_ENV="production" + +# Blockchain Encryption +WALLET_ENCRYPTION_SALT="${WALLET_ENCRYPTION_SALT}" diff --git a/backend/services/identity-service/identity-service/Dockerfile b/backend/services/identity-service/identity-service/Dockerfile new file mode 100644 index 00000000..f9d1b1da --- /dev/null +++ b/backend/services/identity-service/identity-service/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY src/infrastructure/persistence/prisma ./src/infrastructure/persistence/prisma/ + +RUN npm ci + +COPY . . + +RUN npm run prisma:generate +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/src/infrastructure/persistence/prisma ./src/infrastructure/persistence/prisma +COPY --from=builder /app/package*.json ./ + +ENV NODE_ENV=production + +EXPOSE 3000 + +CMD ["npm", "run", "start:prod"] diff --git a/backend/services/identity-service/identity-service/README.md b/backend/services/identity-service/identity-service/README.md new file mode 100644 index 00000000..93c5c68b --- /dev/null +++ b/backend/services/identity-service/identity-service/README.md @@ -0,0 +1,124 @@ +# Identity Service + +RWA用户身份上下文微服务 - 基于DDD架构的NestJS实现 + +## 技术栈 + +- **框架**: NestJS + TypeScript +- **ORM**: Prisma +- **消息队列**: Kafka +- **缓存**: Redis (ioredis) +- **区块链**: ethers.js + @scure/bip32 + bech32 + +## 项目结构 + +``` +src/ +├── api/ # 表现层 +│ ├── controllers/ # 控制器 +│ └── dto/ # 请求/响应DTO +├── application/ # 应用层 +│ ├── commands/ # 命令对象 +│ └── services/ # 应用服务 +├── domain/ # 领域层 +│ ├── aggregates/ # 聚合根 +│ ├── entities/ # 实体 +│ ├── events/ # 领域事件 +│ ├── repositories/ # 仓储接口 +│ ├── services/ # 领域服务 +│ └── value-objects/ # 值对象 +├── infrastructure/ # 基础设施层 +│ ├── persistence/ # 持久化 +│ ├── redis/ # Redis服务 +│ ├── kafka/ # Kafka事件发布 +│ └── external/ # 外部服务 +├── shared/ # 共享层 +│ ├── decorators/ # 装饰器 +│ ├── guards/ # 守卫 +│ ├── filters/ # 过滤器 +│ └── exceptions/ # 异常类 +└── config/ # 配置 +``` + +## 核心功能 + +- ✅ 用户账户自动创建(首次打开APP) +- ✅ 多设备管理与授权(最多5个设备) +- ✅ 三链钱包地址生成(KAVA/DST/BSC) +- ✅ 助记词生成与加密存储 +- ✅ 序列号+助记词恢复账户 +- ✅ 序列号+手机号恢复账户 +- ✅ KYC实名认证 +- ✅ 推荐码生成与验证 +- ✅ Token自动刷新机制 + +## 快速开始 + +### 1. 安装依赖 + +```bash +npm install +``` + +### 2. 配置环境变量 + +```bash +cp .env.example .env +# 编辑 .env 文件配置数据库等信息 +``` + +### 3. 初始化数据库 + +```bash +npm run prisma:generate +npm run prisma:migrate +``` + +### 4. 启动服务 + +```bash +# 开发模式 +npm run start:dev + +# 生产模式 +npm run build +npm run start:prod +``` + +### 5. Docker部署 + +```bash +docker-compose up -d +``` + +## API文档 + +启动服务后访问: http://localhost:3000/api/docs + +## 主要API + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /user/auto-create | 自动创建账户 | +| POST | /user/recover-by-mnemonic | 助记词恢复 | +| POST | /user/recover-by-phone | 手机号恢复 | +| POST | /user/auto-login | 自动登录 | +| GET | /user/my-profile | 我的资料 | +| GET | /user/my-devices | 我的设备 | +| POST | /user/bind-phone | 绑定手机号 | +| POST | /user/submit-kyc | 提交KYC | + +## 领域不变式 + +1. 手机号在系统内唯一(可为空) +2. 账户序列号全局唯一且递增 +3. 每个账户最多5个设备同时登录 +4. KYC认证通过后身份信息不可修改 +5. 每个区块链地址只能绑定一个账户 +6. 推荐人序列号一旦设置终生不可修改 +7. 助记词必须加密存储,只在创建时返回一次 +8. 三条链的钱包地址必须从同一个助记词派生 + +## License + +Proprietary diff --git a/backend/services/identity-service/identity-service/database/init.sql b/backend/services/identity-service/identity-service/database/init.sql new file mode 100644 index 00000000..f713b066 --- /dev/null +++ b/backend/services/identity-service/identity-service/database/init.sql @@ -0,0 +1,7 @@ +-- ============================================ +-- Identity Context 数据库初始化 (PostgreSQL) +-- ============================================ + +-- 初始化账户序列号生成器 +INSERT INTO account_sequence_generator (id, current_sequence) VALUES (1, 0) +ON CONFLICT (id) DO NOTHING; diff --git a/backend/services/identity-service/identity-service/docker-compose.yml b/backend/services/identity-service/identity-service/docker-compose.yml new file mode 100644 index 00000000..6e8f77a9 --- /dev/null +++ b/backend/services/identity-service/identity-service/docker-compose.yml @@ -0,0 +1,69 @@ +version: '3.8' + +services: + identity-service: + build: . + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/rwa_identity?schema=public + - JWT_SECRET=your-super-secret-jwt-key-change-in-production + - JWT_ACCESS_EXPIRES_IN=2h + - JWT_REFRESH_EXPIRES_IN=30d + - REDIS_HOST=redis + - REDIS_PORT=6379 + - KAFKA_BROKERS=kafka:9092 + - APP_PORT=3000 + - APP_ENV=production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + kafka: + condition: service_started + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=rwa_identity + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-kafka:7.5.0 + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + +volumes: + postgres_data: + redis_data: diff --git a/backend/services/identity-service/identity-service/nest-cli.json b/backend/services/identity-service/identity-service/nest-cli.json new file mode 100644 index 00000000..f5e93169 --- /dev/null +++ b/backend/services/identity-service/identity-service/nest-cli.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "classValidatorShim": true, + "introspectComments": true + } + } + ] + } +} diff --git a/backend/services/identity-service/identity-service/package.json b/backend/services/identity-service/identity-service/package.json new file mode 100644 index 00000000..fc9db1c0 --- /dev/null +++ b/backend/services/identity-service/identity-service/package.json @@ -0,0 +1,87 @@ +{ + "name": "identity-service", + "version": "1.0.0", + "description": "RWA Identity & User Context Service", + "author": "RWA Team", + "private": true, + "license": "UNLICENSED", + "prisma": { + "schema": "src/infrastructure/persistence/prisma/schema.prisma" + }, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:prod": "prisma migrate deploy", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.17", + "@prisma/client": "^5.7.0", + "@scure/bip32": "^1.3.2", + "@scure/bip39": "^1.2.1", + "bech32": "^2.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "ethers": "^6.9.0", + "ioredis": "^5.3.2", + "kafkajs": "^2.2.4", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/uuid": "^9.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "prisma": "^5.7.0", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^@/(.*)$": "/$1" + } + } +} diff --git a/backend/services/identity-service/identity-service/src/api/api.module.ts b/backend/services/identity-service/identity-service/src/api/api.module.ts new file mode 100644 index 00000000..ab8c73ee --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/api.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UserAccountController } from './controllers/user-account.controller'; +import { AuthController } from './controllers/auth.controller'; +import { ApplicationModule } from '@/application/application.module'; + +@Module({ + imports: [ApplicationModule], + controllers: [UserAccountController, AuthController], +}) +export class ApiModule {} diff --git a/backend/services/identity-service/identity-service/src/api/controllers/auth.controller.ts b/backend/services/identity-service/identity-service/src/api/controllers/auth.controller.ts new file mode 100644 index 00000000..8664c668 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/controllers/auth.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Post, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { UserApplicationService } from '@/application/services/user-application.service'; +import { Public } from '@/shared/guards/jwt-auth.guard'; +import { AutoLoginCommand } from '@/application/commands'; +import { AutoLoginDto } from '@/api/dto'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly userService: UserApplicationService) {} + + @Public() + @Post('refresh') + @ApiOperation({ summary: 'Token刷新' }) + async refresh(@Body() dto: AutoLoginDto) { + return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId)); + } +} diff --git a/backend/services/identity-service/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/identity-service/src/api/controllers/user-account.controller.ts new file mode 100644 index 00000000..520095cc --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/controllers/user-account.controller.ts @@ -0,0 +1,169 @@ +import { Controller, Post, Get, Put, Body, Param, UseGuards, Headers } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { UserApplicationService } from '@/application/services/user-application.service'; +import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard'; +import { + AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, + AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand, + UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand, + GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, +} from '@/application/commands'; +import { + AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto, + SendSmsCodeDto, RegisterDto, LoginDto, BindPhoneDto, UpdateProfileDto, + BindWalletDto, SubmitKYCDto, RemoveDeviceDto, + AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto, + UserProfileResponseDto, DeviceResponseDto, +} from '@/api/dto'; + +@ApiTags('User') +@Controller('user') +@UseGuards(JwtAuthGuard) +export class UserAccountController { + constructor(private readonly userService: UserApplicationService) {} + + @Public() + @Post('auto-create') + @ApiOperation({ summary: '自动创建账户(首次打开APP)' }) + @ApiResponse({ status: 200, type: AutoCreateAccountResponseDto }) + async autoCreate(@Body() dto: AutoCreateAccountDto) { + return this.userService.autoCreateAccount( + new AutoCreateAccountCommand( + dto.deviceId, dto.deviceName, dto.inviterReferralCode, + dto.provinceCode, dto.cityCode, + ), + ); + } + + @Public() + @Post('recover-by-mnemonic') + @ApiOperation({ summary: '用序列号+助记词恢复账户' }) + @ApiResponse({ status: 200, type: RecoverAccountResponseDto }) + async recoverByMnemonic(@Body() dto: RecoverByMnemonicDto) { + return this.userService.recoverByMnemonic( + new RecoverByMnemonicCommand( + dto.accountSequence, dto.mnemonic, dto.newDeviceId, dto.deviceName, + ), + ); + } + + @Public() + @Post('recover-by-phone') + @ApiOperation({ summary: '用序列号+手机号恢复账户' }) + @ApiResponse({ status: 200, type: RecoverAccountResponseDto }) + async recoverByPhone(@Body() dto: RecoverByPhoneDto) { + return this.userService.recoverByPhone( + new RecoverByPhoneCommand( + dto.accountSequence, dto.phoneNumber, dto.smsCode, + dto.newDeviceId, dto.deviceName, + ), + ); + } + + @Public() + @Post('auto-login') + @ApiOperation({ summary: '自动登录(Token刷新)' }) + @ApiResponse({ status: 200, type: LoginResponseDto }) + async autoLogin(@Body() dto: AutoLoginDto) { + return this.userService.autoLogin( + new AutoLoginCommand(dto.refreshToken, dto.deviceId), + ); + } + + @Public() + @Post('send-sms-code') + @ApiOperation({ summary: '发送短信验证码' }) + async sendSmsCode(@Body() dto: SendSmsCodeDto) { + await this.userService.sendSmsCode(new SendSmsCodeCommand(dto.phoneNumber, dto.type)); + return { message: '验证码已发送' }; + } + + @Public() + @Post('register') + @ApiOperation({ summary: '用户注册(手机号)' }) + @ApiResponse({ status: 200, type: LoginResponseDto }) + async register(@Body() dto: RegisterDto) { + return this.userService.register( + new RegisterCommand( + dto.phoneNumber, dto.smsCode, dto.deviceId, + dto.provinceCode, dto.cityCode, dto.deviceName, dto.inviterReferralCode, + ), + ); + } + + @Public() + @Post('login') + @ApiOperation({ summary: '用户登录(手机号)' }) + @ApiResponse({ status: 200, type: LoginResponseDto }) + async login(@Body() dto: LoginDto) { + return this.userService.login( + new LoginCommand(dto.phoneNumber, dto.smsCode, dto.deviceId), + ); + } + + @Post('bind-phone') + @ApiBearerAuth() + @ApiOperation({ summary: '绑定手机号' }) + async bindPhone(@CurrentUser() user: CurrentUserData, @Body() dto: BindPhoneDto) { + await this.userService.bindPhoneNumber( + new BindPhoneNumberCommand(user.userId, dto.phoneNumber, dto.smsCode), + ); + return { message: '绑定成功' }; + } + + @Get('my-profile') + @ApiBearerAuth() + @ApiOperation({ summary: '查询我的资料' }) + @ApiResponse({ status: 200, type: UserProfileResponseDto }) + async getMyProfile(@CurrentUser() user: CurrentUserData) { + return this.userService.getMyProfile(new GetMyProfileQuery(user.userId)); + } + + @Put('update-profile') + @ApiBearerAuth() + @ApiOperation({ summary: '更新用户资料' }) + async updateProfile(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateProfileDto) { + await this.userService.updateProfile( + new UpdateProfileCommand(user.userId, dto.nickname, dto.avatarUrl, dto.address), + ); + return { message: '更新成功' }; + } + + @Post('submit-kyc') + @ApiBearerAuth() + @ApiOperation({ summary: '提交KYC认证' }) + async submitKYC(@CurrentUser() user: CurrentUserData, @Body() dto: SubmitKYCDto) { + await this.userService.submitKYC( + new SubmitKYCCommand( + user.userId, dto.realName, dto.idCardNumber, + dto.idCardFrontUrl, dto.idCardBackUrl, + ), + ); + return { message: '提交成功' }; + } + + @Get('my-devices') + @ApiBearerAuth() + @ApiOperation({ summary: '查看我的设备列表' }) + @ApiResponse({ status: 200, type: [DeviceResponseDto] }) + async getMyDevices(@CurrentUser() user: CurrentUserData) { + return this.userService.getMyDevices(new GetMyDevicesQuery(user.userId, user.deviceId)); + } + + @Post('remove-device') + @ApiBearerAuth() + @ApiOperation({ summary: '移除设备' }) + async removeDevice(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveDeviceDto) { + await this.userService.removeDevice( + new RemoveDeviceCommand(user.userId, user.deviceId, dto.deviceId), + ); + return { message: '移除成功' }; + } + + @Public() + @Get('by-referral-code/:code') + @ApiOperation({ summary: '根据推荐码查询用户' }) + async getByReferralCode(@Param('code') code: string) { + return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code)); + } +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/index.ts b/backend/services/identity-service/identity-service/src/api/dto/index.ts new file mode 100644 index 00000000..69d8003c --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/index.ts @@ -0,0 +1,182 @@ +// Request DTOs +export * from './request'; + +// Response DTOs +export * from './response'; + +// 其他通用DTOs +import { IsString, IsOptional, IsNotEmpty, Matches, IsEnum, IsNumber } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AutoLoginDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + refreshToken: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + deviceId: string; +} + +export class SendSmsCodeDto { + @ApiProperty({ example: '13800138000' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] }) + @IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER']) + type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'; +} + +export class RegisterDto { + @ApiProperty({ example: '13800138000' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + deviceId: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + provinceCode: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + cityCode: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + deviceName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inviterReferralCode?: string; +} + +export class LoginDto { + @ApiProperty({ example: '13800138000' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + deviceId: string; +} + +export class UpdateProfileDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + nickname?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + address?: string; +} + +export class BindWalletDto { + @ApiProperty({ enum: ['KAVA', 'DST', 'BSC'] }) + @IsEnum(['KAVA', 'DST', 'BSC']) + chainType: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + address: string; +} + +export class RemoveDeviceDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + deviceId: string; +} + +// Response DTOs +export class AutoCreateAccountResponseDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty() + referralCode: string; + + @ApiProperty({ description: '助记词(仅返回一次,请妥善保管)' }) + mnemonic: string; + + @ApiProperty() + walletAddresses: { kava: string; dst: string; bsc: string }; + + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; +} + +export class RecoverAccountResponseDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty() + nickname: string; + + @ApiProperty({ nullable: true }) + avatarUrl: string | null; + + @ApiProperty() + referralCode: string; + + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; +} + +export class LoginResponseDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/auto-create-account.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/request/auto-create-account.dto.ts new file mode 100644 index 00000000..82ca0f31 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/auto-create-account.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AutoCreateAccountDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsString() + @IsNotEmpty() + deviceId: string; + + @ApiPropertyOptional({ example: 'iPhone 15 Pro' }) + @IsOptional() + @IsString() + deviceName?: string; + + @ApiPropertyOptional({ example: 'ABC123' }) + @IsOptional() + @IsString() + @Matches(/^[A-Z0-9]{6}$/, { message: '推荐码格式错误' }) + inviterReferralCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + provinceCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cityCode?: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/bind-phone.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/request/bind-phone.dto.ts new file mode 100644 index 00000000..58a5b5c7 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/bind-phone.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class BindPhoneDto { + @ApiProperty({ example: '13800138000' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/identity-service/src/api/dto/request/index.ts new file mode 100644 index 00000000..16fffe6a --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/index.ts @@ -0,0 +1,5 @@ +export * from './auto-create-account.dto'; +export * from './recover-by-mnemonic.dto'; +export * from './recover-by-phone.dto'; +export * from './bind-phone.dto'; +export * from './submit-kyc.dto'; diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-mnemonic.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-mnemonic.dto.ts new file mode 100644 index 00000000..b11209a3 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-mnemonic.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional, IsNotEmpty, IsNumber } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RecoverByMnemonicDto { + @ApiProperty({ example: 10001 }) + @IsNumber() + accountSequence: number; + + @ApiProperty({ example: 'abandon ability able about above absent absorb abstract absurd abuse access accident' }) + @IsString() + @IsNotEmpty() + mnemonic: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + newDeviceId: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + deviceName?: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-phone.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-phone.dto.ts new file mode 100644 index 00000000..1a06f423 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/recover-by-phone.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional, IsNotEmpty, IsNumber, Matches } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RecoverByPhoneDto { + @ApiProperty({ example: 10001 }) + @IsNumber() + accountSequence: number; + + @ApiProperty({ example: '13800138000' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + newDeviceId: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + deviceName?: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/request/submit-kyc.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/request/submit-kyc.dto.ts new file mode 100644 index 00000000..343025f4 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/request/submit-kyc.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsNotEmpty, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SubmitKycDto { + @ApiProperty({ example: '张三' }) + @IsString() + @IsNotEmpty() + realName: string; + + @ApiProperty({ example: '110101199001011234' }) + @IsString() + @Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/, { message: '身份证号格式错误' }) + idCardNumber: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + idCardFrontUrl: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + idCardBackUrl: string; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/response/device.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/response/device.dto.ts new file mode 100644 index 00000000..8d2fbdfe --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/response/device.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DeviceDto { + @ApiProperty() + deviceId: string; + + @ApiProperty() + deviceName: string; + + @ApiProperty() + addedAt: Date; + + @ApiProperty() + lastActiveAt: Date; + + @ApiProperty() + isCurrent: boolean; +} diff --git a/backend/services/identity-service/identity-service/src/api/dto/response/index.ts b/backend/services/identity-service/identity-service/src/api/dto/response/index.ts new file mode 100644 index 00000000..1e8628d1 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/response/index.ts @@ -0,0 +1,2 @@ +export * from './user-profile.dto'; +export * from './device.dto'; diff --git a/backend/services/identity-service/identity-service/src/api/dto/response/user-profile.dto.ts b/backend/services/identity-service/identity-service/src/api/dto/response/user-profile.dto.ts new file mode 100644 index 00000000..ab311e3a --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/dto/response/user-profile.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class WalletAddressDto { + @ApiProperty() + chainType: string; + + @ApiProperty() + address: string; +} + +export class KycInfoDto { + @ApiProperty() + realName: string; + + @ApiProperty() + idCardNumber: string; +} + +export class UserProfileDto { + @ApiProperty() + userId: string; + + @ApiProperty() + accountSequence: number; + + @ApiProperty({ nullable: true }) + phoneNumber: string | null; + + @ApiProperty() + nickname: string; + + @ApiProperty({ nullable: true }) + avatarUrl: string | null; + + @ApiProperty() + referralCode: string; + + @ApiProperty() + province: string; + + @ApiProperty() + city: string; + + @ApiProperty({ nullable: true }) + address: string | null; + + @ApiProperty({ type: [WalletAddressDto] }) + walletAddresses: WalletAddressDto[]; + + @ApiProperty() + kycStatus: string; + + @ApiProperty({ type: KycInfoDto, nullable: true }) + kycInfo: KycInfoDto | null; + + @ApiProperty() + status: string; + + @ApiProperty() + registeredAt: Date; + + @ApiProperty({ nullable: true }) + lastLoginAt: Date | null; +} diff --git a/backend/services/identity-service/identity-service/src/api/validators/phone.validator.ts b/backend/services/identity-service/identity-service/src/api/validators/phone.validator.ts new file mode 100644 index 00000000..ba383d59 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/api/validators/phone.validator.ts @@ -0,0 +1,47 @@ +import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator, ValidationOptions } from 'class-validator'; + +@ValidatorConstraint({ name: 'isChinesePhone', async: false }) +export class IsChinesePhoneConstraint implements ValidatorConstraintInterface { + validate(phone: string, args: ValidationArguments): boolean { + return /^1[3-9]\d{9}$/.test(phone); + } + + defaultMessage(args: ValidationArguments): string { + return '手机号格式错误'; + } +} + +export function IsChinesePhone(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsChinesePhoneConstraint, + }); + }; +} + +@ValidatorConstraint({ name: 'isChineseIdCard', async: false }) +export class IsChineseIdCardConstraint implements ValidatorConstraintInterface { + validate(idCard: string, args: ValidationArguments): boolean { + return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCard); + } + + defaultMessage(args: ValidationArguments): string { + return '身份证号格式错误'; + } +} + +export function IsChineseIdCard(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsChineseIdCardConstraint, + }); + }; +} diff --git a/backend/services/identity-service/identity-service/src/app.module.ts b/backend/services/identity-service/identity-service/src/app.module.ts new file mode 100644 index 00000000..9f274d2e --- /dev/null +++ b/backend/services/identity-service/identity-service/src/app.module.ts @@ -0,0 +1,100 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core'; + +// Config +import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig } from '@/config'; + +// Controllers +import { UserAccountController } from '@/api/controllers/user-account.controller'; + +// Application Services +import { UserApplicationService } from '@/application/services/user-application.service'; +import { TokenService } from '@/application/services/token.service'; + +// Domain Services +import { + AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService, +} from '@/domain/services'; +import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; + +// Infrastructure +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl'; +import { RedisService } from '@/infrastructure/redis/redis.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { SmsService } from '@/infrastructure/external/sms/sms.service'; + +// Shared +import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter'; +import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; + +// ============ Infrastructure Module ============ +@Global() +@Module({ + providers: [PrismaService, RedisService, EventPublisherService, SmsService], + exports: [PrismaService, RedisService, EventPublisherService, SmsService], +}) +export class InfrastructureModule {} + +// ============ Domain Module ============ +@Module({ + imports: [InfrastructureModule], + providers: [ + { provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl }, + AccountSequenceGeneratorService, + UserValidatorService, + WalletGeneratorService, + ], + exports: [ + USER_ACCOUNT_REPOSITORY, + AccountSequenceGeneratorService, + UserValidatorService, + WalletGeneratorService, + ], +}) +export class DomainModule {} + +// ============ Application Module ============ +@Module({ + imports: [DomainModule, InfrastructureModule], + providers: [UserApplicationService, TokenService], + exports: [UserApplicationService, TokenService], +}) +export class ApplicationModule {} + +// ============ API Module ============ +@Module({ + imports: [ApplicationModule], + controllers: [UserAccountController], +}) +export class ApiModule {} + +// ============ App Module ============ +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConfig, walletConfig], + }), + JwtModule.registerAsync({ + global: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { expiresIn: configService.get('JWT_ACCESS_EXPIRES_IN', '2h') }, + }), + }), + InfrastructureModule, + DomainModule, + ApplicationModule, + ApiModule, + ], + providers: [ + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + ], +}) +export class AppModule {} diff --git a/backend/services/identity-service/identity-service/src/application/application.module.ts b/backend/services/identity-service/identity-service/src/application/application.module.ts new file mode 100644 index 00000000..f7a3f643 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/application.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { UserApplicationService } from './services/user-application.service'; +import { TokenService } from './services/token.service'; +import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler'; +import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler'; +import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler'; +import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler'; +import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler'; +import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler'; +import { DomainModule } from '@/domain/domain.module'; +import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; + +@Module({ + imports: [DomainModule, InfrastructureModule], + providers: [ + UserApplicationService, + TokenService, + AutoCreateAccountHandler, + RecoverByMnemonicHandler, + RecoverByPhoneHandler, + BindPhoneHandler, + GetMyProfileHandler, + GetMyDevicesHandler, + ], + exports: [ + UserApplicationService, + TokenService, + AutoCreateAccountHandler, + RecoverByMnemonicHandler, + RecoverByPhoneHandler, + BindPhoneHandler, + GetMyProfileHandler, + GetMyDevicesHandler, + ], +}) +export class ApplicationModule {} diff --git a/backend/services/identity-service/identity-service/src/application/commands/auto-create-account/auto-create-account.command.ts b/backend/services/identity-service/identity-service/src/application/commands/auto-create-account/auto-create-account.command.ts new file mode 100644 index 00000000..08ded2df --- /dev/null +++ b/backend/services/identity-service/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, + public readonly inviterReferralCode?: string, + public readonly provinceCode?: string, + public readonly cityCode?: string, + ) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/auto-create-account/auto-create-account.handler.ts b/backend/services/identity-service/identity-service/src/application/commands/auto-create-account/auto-create-account.handler.ts new file mode 100644 index 00000000..54a2ebc6 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/auto-create-account/auto-create-account.handler.ts @@ -0,0 +1,80 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { AutoCreateAccountCommand } from './auto-create-account.command'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services'; +import { ReferralCode, AccountSequence, ProvinceCode, CityCode, ChainType } from '@/domain/value-objects'; +import { TokenService } from '@/application/services/token.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { AutoCreateAccountResult } from '../index'; + +@Injectable() +export class AutoCreateAccountHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly sequenceGenerator: AccountSequenceGeneratorService, + private readonly validatorService: UserValidatorService, + private readonly walletGenerator: WalletGeneratorService, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute(command: AutoCreateAccountCommand): Promise { + const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId); + if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!); + + let inviterSequence: AccountSequence | null = null; + if (command.inviterReferralCode) { + const referralCode = ReferralCode.create(command.inviterReferralCode); + const referralValidation = await this.validatorService.validateReferralCode(referralCode); + if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!); + const inviter = await this.userRepository.findByReferralCode(referralCode); + inviterSequence = inviter!.accountSequence; + } + + const accountSequence = await this.sequenceGenerator.generateNext(); + + const account = UserAccount.createAutomatic({ + accountSequence, + initialDeviceId: command.deviceId, + deviceName: command.deviceName, + inviterSequence, + province: ProvinceCode.create(command.provinceCode || 'DEFAULT'), + city: CityCode.create(command.cityCode || 'DEFAULT'), + }); + + const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({ + userId: account.userId, + deviceId: command.deviceId, + }); + + account.bindMultipleWalletAddresses(wallets); + await this.userRepository.save(account); + await this.userRepository.saveWallets(account.userId, Array.from(wallets.values())); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + referralCode: account.referralCode.value, + mnemonic: mnemonic.value, + walletAddresses: { + kava: wallets.get(ChainType.KAVA)!.address, + dst: wallets.get(ChainType.DST)!.address, + bsc: wallets.get(ChainType.BSC)!.address, + }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.command.ts b/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.command.ts new file mode 100644 index 00000000..65730ed5 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.command.ts @@ -0,0 +1,7 @@ +export class BindPhoneCommand { + constructor( + public readonly userId: string, + public readonly phoneNumber: string, + public readonly smsCode: string, + ) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.handler.ts b/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.handler.ts new file mode 100644 index 00000000..18305e43 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/bind-phone/bind-phone.handler.ts @@ -0,0 +1,37 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { BindPhoneCommand } from './bind-phone.command'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { UserValidatorService } from '@/domain/services'; +import { UserId, PhoneNumber } from '@/domain/value-objects'; +import { RedisService } from '@/infrastructure/redis/redis.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; + +@Injectable() +export class BindPhoneHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly validatorService: UserValidatorService, + private readonly redisService: RedisService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute(command: BindPhoneCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + const phoneNumber = PhoneNumber.create(command.phoneNumber); + const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + const validation = await this.validatorService.validatePhoneNumber(phoneNumber); + if (!validation.isValid) throw new ApplicationError(validation.errorMessage!); + + account.bindPhoneNumber(phoneNumber); + await this.userRepository.save(account); + await this.redisService.delete(`sms:bind:${phoneNumber.value}`); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + } +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/index.ts b/backend/services/identity-service/identity-service/src/application/commands/index.ts new file mode 100644 index 00000000..4c85a5ce --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/index.ts @@ -0,0 +1,206 @@ +// ============ Commands ============ +export class AutoCreateAccountCommand { + constructor( + public readonly deviceId: string, + public readonly deviceName?: string, + public readonly inviterReferralCode?: string, + public readonly provinceCode?: string, + public readonly cityCode?: string, + ) {} +} + +export class RecoverByMnemonicCommand { + constructor( + public readonly accountSequence: number, + public readonly mnemonic: string, + public readonly newDeviceId: string, + public readonly deviceName?: string, + ) {} +} + +export class RecoverByPhoneCommand { + constructor( + public readonly accountSequence: number, + public readonly phoneNumber: string, + public readonly smsCode: string, + public readonly newDeviceId: string, + public readonly deviceName?: string, + ) {} +} + +export class AutoLoginCommand { + constructor( + public readonly refreshToken: string, + public readonly deviceId: string, + ) {} +} + +export class RegisterCommand { + constructor( + public readonly phoneNumber: string, + public readonly smsCode: string, + public readonly deviceId: string, + public readonly provinceCode: string, + public readonly cityCode: string, + public readonly deviceName?: string, + public readonly inviterReferralCode?: string, + ) {} +} + +export class LoginCommand { + constructor( + public readonly phoneNumber: string, + public readonly smsCode: string, + public readonly deviceId: string, + ) {} +} + +export class BindPhoneNumberCommand { + constructor( + public readonly userId: string, + public readonly phoneNumber: string, + public readonly smsCode: string, + ) {} +} + +export class UpdateProfileCommand { + constructor( + public readonly userId: string, + public readonly nickname?: string, + public readonly avatarUrl?: string, + public readonly address?: string, + ) {} +} + +export class BindWalletAddressCommand { + constructor( + public readonly userId: string, + public readonly chainType: string, + public readonly address: string, + ) {} +} + +export class SubmitKYCCommand { + constructor( + public readonly userId: string, + public readonly realName: string, + public readonly idCardNumber: string, + public readonly idCardFrontUrl: string, + public readonly idCardBackUrl: string, + ) {} +} + +export class ReviewKYCCommand { + constructor( + public readonly userId: string, + public readonly approved: boolean, + public readonly reason?: string, + ) {} +} + +export class RemoveDeviceCommand { + constructor( + public readonly userId: string, + public readonly currentDeviceId: string, + public readonly deviceIdToRemove: string, + ) {} +} + +export class SendSmsCodeCommand { + constructor( + public readonly phoneNumber: string, + public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER', + ) {} +} + +// ============ Queries ============ +export class GetMyProfileQuery { + constructor(public readonly userId: string) {} +} + +export class GetMyDevicesQuery { + constructor( + public readonly userId: string, + public readonly currentDeviceId: string, + ) {} +} + +export class GetUserByReferralCodeQuery { + constructor(public readonly referralCode: string) {} +} + +// ============ Results ============ +export interface AutoCreateAccountResult { + userId: string; + accountSequence: number; + referralCode: string; + mnemonic: string; + walletAddresses: { kava: string; dst: string; bsc: string }; + accessToken: string; + refreshToken: string; +} + +export interface RecoverAccountResult { + userId: string; + accountSequence: number; + nickname: string; + avatarUrl: string | null; + referralCode: string; + accessToken: string; + refreshToken: string; +} + +export interface AutoLoginResult { + userId: string; + accountSequence: number; + accessToken: string; + refreshToken: string; +} + +export interface RegisterResult { + userId: string; + accountSequence: number; + referralCode: string; + accessToken: string; + refreshToken: string; +} + +export interface LoginResult { + userId: string; + accountSequence: number; + accessToken: string; + refreshToken: string; +} + +export interface UserProfileDTO { + userId: string; + accountSequence: number; + phoneNumber: string | null; + nickname: string; + avatarUrl: string | null; + referralCode: string; + province: string; + city: string; + address: string | null; + walletAddresses: Array<{ chainType: string; address: string }>; + kycStatus: string; + kycInfo: { realName: string; idCardNumber: string } | null; + status: string; + registeredAt: Date; + lastLoginAt: Date | null; +} + +export interface DeviceDTO { + deviceId: string; + deviceName: string; + addedAt: Date; + lastActiveAt: Date; + isCurrent: boolean; +} + +export interface UserBriefDTO { + userId: string; + accountSequence: number; + nickname: string; + avatarUrl: string | null; +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.command.ts b/backend/services/identity-service/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.command.ts new file mode 100644 index 00000000..fb3a66e9 --- /dev/null +++ b/backend/services/identity-service/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, + ) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.handler.ts b/backend/services/identity-service/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.handler.ts new file mode 100644 index 00000000..5246391d --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/recover-by-mnemonic/recover-by-mnemonic.handler.ts @@ -0,0 +1,62 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { RecoverByMnemonicCommand } from './recover-by-mnemonic.command'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { WalletGeneratorService } from '@/domain/services'; +import { AccountSequence, ChainType, Mnemonic } from '@/domain/value-objects'; +import { TokenService } from '@/application/services/token.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { RecoverAccountResult } from '../index'; + +@Injectable() +export class RecoverByMnemonicHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly walletGenerator: WalletGeneratorService, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute(command: RecoverByMnemonicCommand): Promise { + const accountSequence = AccountSequence.create(command.accountSequence); + const account = await this.userRepository.findByAccountSequence(accountSequence); + if (!account) throw new ApplicationError('账户序列号不存在'); + if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); + + const mnemonic = Mnemonic.create(command.mnemonic); + const wallets = this.walletGenerator.recoverWalletSystem({ + userId: account.userId, + mnemonic, + deviceId: command.newDeviceId, + }); + + const kavaWallet = account.getWalletAddress(ChainType.KAVA); + if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) { + throw new ApplicationError('助记词错误'); + } + + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(); + await this.userRepository.save(account); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/recover-by-phone/recover-by-phone.command.ts b/backend/services/identity-service/identity-service/src/application/commands/recover-by-phone/recover-by-phone.command.ts new file mode 100644 index 00000000..ae19629c --- /dev/null +++ b/backend/services/identity-service/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, + ) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/commands/recover-by-phone/recover-by-phone.handler.ts b/backend/services/identity-service/identity-service/src/application/commands/recover-by-phone/recover-by-phone.handler.ts new file mode 100644 index 00000000..cebd6d23 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/commands/recover-by-phone/recover-by-phone.handler.ts @@ -0,0 +1,58 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { RecoverByPhoneCommand } from './recover-by-phone.command'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { AccountSequence, PhoneNumber } from '@/domain/value-objects'; +import { TokenService } from '@/application/services/token.service'; +import { RedisService } from '@/infrastructure/redis/redis.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { RecoverAccountResult } from '../index'; + +@Injectable() +export class RecoverByPhoneHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly tokenService: TokenService, + private readonly redisService: RedisService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async execute(command: RecoverByPhoneCommand): Promise { + const accountSequence = AccountSequence.create(command.accountSequence); + const account = await this.userRepository.findByAccountSequence(accountSequence); + if (!account) throw new ApplicationError('账户序列号不存在'); + if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); + if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复'); + + const phoneNumber = PhoneNumber.create(command.phoneNumber); + if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配'); + + const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(); + await this.userRepository.save(account); + await this.redisService.delete(`sms:recover:${phoneNumber.value}`); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } +} diff --git a/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.handler.ts b/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.handler.ts new file mode 100644 index 00000000..6628acd3 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.handler.ts @@ -0,0 +1,27 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { GetMyDevicesQuery } from './get-my-devices.query'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { UserId } from '@/domain/value-objects'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { DeviceDTO } from '@/application/commands'; + +@Injectable() +export class GetMyDevicesHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + ) {} + + async execute(query: GetMyDevicesQuery): Promise { + const account = await this.userRepository.findById(UserId.create(query.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + return account.getAllDevices().map((device) => ({ + deviceId: device.deviceId, + deviceName: device.deviceName, + addedAt: device.addedAt, + lastActiveAt: device.lastActiveAt, + isCurrent: device.deviceId === query.currentDeviceId, + })); + } +} diff --git a/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.query.ts b/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.query.ts new file mode 100644 index 00000000..e68fbf50 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/queries/get-my-devices/get-my-devices.query.ts @@ -0,0 +1,6 @@ +export class GetMyDevicesQuery { + constructor( + public readonly userId: string, + public readonly currentDeviceId: string, + ) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.handler.ts b/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.handler.ts new file mode 100644 index 00000000..8d5d67c1 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.handler.ts @@ -0,0 +1,46 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { GetMyProfileQuery } from './get-my-profile.query'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { UserId } from '@/domain/value-objects'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { UserProfileDTO } from '@/application/commands'; + +@Injectable() +export class GetMyProfileHandler { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + ) {} + + async execute(query: GetMyProfileQuery): Promise { + const account = await this.userRepository.findById(UserId.create(query.userId)); + if (!account) throw new ApplicationError('用户不存在'); + return this.toDTO(account); + } + + private toDTO(account: UserAccount): UserProfileDTO { + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + phoneNumber: account.phoneNumber?.masked() || null, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + province: account.province.value, + city: account.city.value, + address: account.addressDetail, + walletAddresses: account.getAllWalletAddresses().map((wa) => ({ + chainType: wa.chainType, + address: wa.address, + })), + kycStatus: account.kycStatus, + kycInfo: account.kycInfo + ? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() } + : null, + status: account.status, + registeredAt: account.registeredAt, + lastLoginAt: account.lastLoginAt, + }; + } +} diff --git a/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.query.ts b/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.query.ts new file mode 100644 index 00000000..c0f7806e --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/queries/get-my-profile/get-my-profile.query.ts @@ -0,0 +1,3 @@ +export class GetMyProfileQuery { + constructor(public readonly userId: string) {} +} diff --git a/backend/services/identity-service/identity-service/src/application/services/token.service.ts b/backend/services/identity-service/identity-service/src/application/services/token.service.ts new file mode 100644 index 00000000..207b9b3a --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/services/token.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { createHash } from 'crypto'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; + +export interface TokenPayload { + userId: string; + accountSequence: number; + deviceId: string; + type: 'access' | 'refresh'; +} + +@Injectable() +export class TokenService { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) {} + + async generateTokenPair(payload: { + userId: string; + accountSequence: number; + deviceId: string; + }): Promise<{ accessToken: string; refreshToken: string }> { + const accessToken = this.jwtService.sign( + { ...payload, type: 'access' }, + { expiresIn: this.configService.get('JWT_ACCESS_EXPIRES_IN', '2h') }, + ); + + const refreshToken = this.jwtService.sign( + { ...payload, type: 'refresh' }, + { expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN', '30d') }, + ); + + // Save refresh token hash + const tokenHash = this.hashToken(refreshToken); + await this.prisma.deviceToken.create({ + data: { + userId: BigInt(payload.userId), + deviceId: payload.deviceId, + refreshTokenHash: tokenHash, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + + return { accessToken, refreshToken }; + } + + async verifyRefreshToken(token: string): Promise<{ + userId: string; + accountSequence: number; + deviceId: string; + }> { + try { + const payload = this.jwtService.verify(token); + if (payload.type !== 'refresh') { + throw new ApplicationError('无效的RefreshToken'); + } + + const tokenHash = this.hashToken(token); + const storedToken = await this.prisma.deviceToken.findUnique({ + where: { refreshTokenHash: tokenHash }, + }); + + if (!storedToken || storedToken.revokedAt) { + throw new ApplicationError('RefreshToken已失效'); + } + + return { + userId: payload.userId, + accountSequence: payload.accountSequence, + deviceId: payload.deviceId, + }; + } catch (error) { + if (error instanceof ApplicationError) throw error; + throw new ApplicationError('RefreshToken已过期或无效'); + } + } + + async revokeDeviceTokens(userId: string, deviceId: string): Promise { + await this.prisma.deviceToken.updateMany({ + where: { userId: BigInt(userId), deviceId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/backend/services/identity-service/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/identity-service/src/application/services/user-application.service.ts new file mode 100644 index 00000000..d38bbba7 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/application/services/user-application.service.ts @@ -0,0 +1,424 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { + AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService, +} from '@/domain/services'; +import { + UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode, + ChainType, Mnemonic, KYCInfo, +} from '@/domain/value-objects'; +import { TokenService } from './token.service'; +import { RedisService } from '@/infrastructure/redis/redis.service'; +import { SmsService } from '@/infrastructure/external/sms/sms.service'; +import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { + AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, + AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand, + UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand, + SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, + AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult, + LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO, +} from '../commands'; + +@Injectable() +export class UserApplicationService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly sequenceGenerator: AccountSequenceGeneratorService, + private readonly validatorService: UserValidatorService, + private readonly walletGenerator: WalletGeneratorService, + private readonly tokenService: TokenService, + private readonly redisService: RedisService, + private readonly smsService: SmsService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async autoCreateAccount(command: AutoCreateAccountCommand): Promise { + const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId); + if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!); + + let inviterSequence: AccountSequence | null = null; + if (command.inviterReferralCode) { + const referralCode = ReferralCode.create(command.inviterReferralCode); + const referralValidation = await this.validatorService.validateReferralCode(referralCode); + if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!); + const inviter = await this.userRepository.findByReferralCode(referralCode); + inviterSequence = inviter!.accountSequence; + } + + const accountSequence = await this.sequenceGenerator.generateNext(); + + const account = UserAccount.createAutomatic({ + accountSequence, + initialDeviceId: command.deviceId, + deviceName: command.deviceName, + inviterSequence, + province: ProvinceCode.create(command.provinceCode || 'DEFAULT'), + city: CityCode.create(command.cityCode || 'DEFAULT'), + }); + + const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({ + userId: account.userId, + deviceId: command.deviceId, + }); + + account.bindMultipleWalletAddresses(wallets); + await this.userRepository.save(account); + await this.userRepository.saveWallets(account.userId, Array.from(wallets.values())); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + referralCode: account.referralCode.value, + mnemonic: mnemonic.value, + walletAddresses: { + kava: wallets.get(ChainType.KAVA)!.address, + dst: wallets.get(ChainType.DST)!.address, + bsc: wallets.get(ChainType.BSC)!.address, + }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async recoverByMnemonic(command: RecoverByMnemonicCommand): Promise { + const accountSequence = AccountSequence.create(command.accountSequence); + const account = await this.userRepository.findByAccountSequence(accountSequence); + if (!account) throw new ApplicationError('账户序列号不存在'); + if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); + + const mnemonic = Mnemonic.create(command.mnemonic); + const wallets = this.walletGenerator.recoverWalletSystem({ + userId: account.userId, + mnemonic, + deviceId: command.newDeviceId, + }); + + const kavaWallet = account.getWalletAddress(ChainType.KAVA); + if (!kavaWallet || kavaWallet.address !== wallets.get(ChainType.KAVA)!.address) { + throw new ApplicationError('助记词错误'); + } + + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(); + await this.userRepository.save(account); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async recoverByPhone(command: RecoverByPhoneCommand): Promise { + const accountSequence = AccountSequence.create(command.accountSequence); + const account = await this.userRepository.findByAccountSequence(accountSequence); + if (!account) throw new ApplicationError('账户序列号不存在'); + if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); + if (!account.phoneNumber) throw new ApplicationError('该账户未绑定手机号,请使用助记词恢复'); + + const phoneNumber = PhoneNumber.create(command.phoneNumber); + if (!account.phoneNumber.equals(phoneNumber)) throw new ApplicationError('手机号与账户不匹配'); + + const cachedCode = await this.redisService.get(`sms:recover:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + account.addDevice(command.newDeviceId, command.deviceName); + account.recordLogin(); + await this.userRepository.save(account); + await this.redisService.delete(`sms:recover:${phoneNumber.value}`); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.newDeviceId, + }); + + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async autoLogin(command: AutoLoginCommand): Promise { + const payload = await this.tokenService.verifyRefreshToken(command.refreshToken); + const account = await this.userRepository.findById(UserId.create(payload.userId)); + if (!account || !account.isActive) throw new ApplicationError('账户不存在或已冻结'); + if (!account.isDeviceAuthorized(command.deviceId)) { + throw new ApplicationError('设备未授权,请重新登录', 'DEVICE_UNAUTHORIZED'); + } + + account.addDevice(command.deviceId); + account.recordLogin(); + await this.userRepository.save(account); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async sendSmsCode(command: SendSmsCodeCommand): Promise { + const phoneNumber = PhoneNumber.create(command.phoneNumber); + const code = this.generateSmsCode(); + const cacheKey = `sms:${command.type.toLowerCase()}:${phoneNumber.value}`; + + await this.smsService.sendVerificationCode(phoneNumber.value, code); + await this.redisService.set(cacheKey, code, 300); + } + + async register(command: RegisterCommand): Promise { + const phoneNumber = PhoneNumber.create(command.phoneNumber); + const cachedCode = await this.redisService.get(`sms:register:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + const phoneValidation = await this.validatorService.validatePhoneNumber(phoneNumber); + if (!phoneValidation.isValid) throw new ApplicationError(phoneValidation.errorMessage!); + + let inviterSequence: AccountSequence | null = null; + if (command.inviterReferralCode) { + const referralCode = ReferralCode.create(command.inviterReferralCode); + const referralValidation = await this.validatorService.validateReferralCode(referralCode); + if (!referralValidation.isValid) throw new ApplicationError(referralValidation.errorMessage!); + const inviter = await this.userRepository.findByReferralCode(referralCode); + inviterSequence = inviter!.accountSequence; + } + + const accountSequence = await this.sequenceGenerator.generateNext(); + + const account = UserAccount.create({ + accountSequence, + phoneNumber, + initialDeviceId: command.deviceId, + deviceName: command.deviceName, + inviterSequence, + province: ProvinceCode.create(command.provinceCode), + city: CityCode.create(command.cityCode), + }); + + await this.userRepository.save(account); + await this.redisService.delete(`sms:register:${phoneNumber.value}`); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + referralCode: account.referralCode.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async login(command: LoginCommand): Promise { + const phoneNumber = PhoneNumber.create(command.phoneNumber); + const cachedCode = await this.redisService.get(`sms:login:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + const account = await this.userRepository.findByPhoneNumber(phoneNumber); + if (!account) throw new ApplicationError('用户不存在'); + if (!account.isActive) throw new ApplicationError('账户已冻结或注销'); + + account.addDevice(command.deviceId); + account.recordLogin(); + await this.userRepository.save(account); + await this.redisService.delete(`sms:login:${phoneNumber.value}`); + + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.value, + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async bindPhoneNumber(command: BindPhoneNumberCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + const phoneNumber = PhoneNumber.create(command.phoneNumber); + const cachedCode = await this.redisService.get(`sms:bind:${phoneNumber.value}`); + if (cachedCode !== command.smsCode) throw new ApplicationError('验证码错误或已过期'); + + const validation = await this.validatorService.validatePhoneNumber(phoneNumber); + if (!validation.isValid) throw new ApplicationError(validation.errorMessage!); + + account.bindPhoneNumber(phoneNumber); + await this.userRepository.save(account); + await this.redisService.delete(`sms:bind:${phoneNumber.value}`); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + } + + async updateProfile(command: UpdateProfileCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + account.updateProfile({ + nickname: command.nickname, + avatarUrl: command.avatarUrl, + address: command.address, + }); + + await this.userRepository.save(account); + } + + async submitKYC(command: SubmitKYCCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + const kycInfo = KYCInfo.create({ + realName: command.realName, + idCardNumber: command.idCardNumber, + idCardFrontUrl: command.idCardFrontUrl, + idCardBackUrl: command.idCardBackUrl, + }); + + account.submitKYC(kycInfo); + await this.userRepository.save(account); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + } + + async reviewKYC(command: ReviewKYCCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + if (command.approved) { + account.approveKYC(); + } else { + account.rejectKYC(command.reason || '审核未通过'); + } + + await this.userRepository.save(account); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + } + + async getMyDevices(query: GetMyDevicesQuery): Promise { + const account = await this.userRepository.findById(UserId.create(query.userId)); + if (!account) throw new ApplicationError('用户不存在'); + + return account.getAllDevices().map((device) => ({ + deviceId: device.deviceId, + deviceName: device.deviceName, + addedAt: device.addedAt, + lastActiveAt: device.lastActiveAt, + isCurrent: device.deviceId === query.currentDeviceId, + })); + } + + async removeDevice(command: RemoveDeviceCommand): Promise { + const account = await this.userRepository.findById(UserId.create(command.userId)); + if (!account) throw new ApplicationError('用户不存在'); + if (command.deviceIdToRemove === command.currentDeviceId) { + throw new ApplicationError('不能删除当前设备'); + } + + account.removeDevice(command.deviceIdToRemove); + await this.userRepository.save(account); + await this.tokenService.revokeDeviceTokens(account.userId.value, command.deviceIdToRemove); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + } + + async getMyProfile(query: GetMyProfileQuery): Promise { + const account = await this.userRepository.findById(UserId.create(query.userId)); + if (!account) throw new ApplicationError('用户不存在'); + return this.toUserProfileDTO(account); + } + + async getUserByReferralCode(query: GetUserByReferralCodeQuery): Promise { + const account = await this.userRepository.findByReferralCode(ReferralCode.create(query.referralCode)); + if (!account) return null; + + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + }; + } + + private toUserProfileDTO(account: UserAccount): UserProfileDTO { + return { + userId: account.userId.value, + accountSequence: account.accountSequence.value, + phoneNumber: account.phoneNumber?.masked() || null, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + referralCode: account.referralCode.value, + province: account.province.value, + city: account.city.value, + address: account.addressDetail, + walletAddresses: account.getAllWalletAddresses().map((wa) => ({ + chainType: wa.chainType, + address: wa.address, + })), + kycStatus: account.kycStatus, + kycInfo: account.kycInfo + ? { realName: account.kycInfo.realName, idCardNumber: account.kycInfo.maskedIdCardNumber() } + : null, + status: account.status, + registeredAt: account.registeredAt, + lastLoginAt: account.lastLoginAt, + }; + } + + private generateSmsCode(): string { + return String(Math.floor(100000 + Math.random() * 900000)); + } +} diff --git a/backend/services/identity-service/identity-service/src/config/app.config.ts b/backend/services/identity-service/identity-service/src/config/app.config.ts new file mode 100644 index 00000000..0a6f3545 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/app.config.ts @@ -0,0 +1,4 @@ +export const appConfig = () => ({ + port: parseInt(process.env.APP_PORT || '3000', 10), + env: process.env.APP_ENV || 'development', +}); diff --git a/backend/services/identity-service/identity-service/src/config/database.config.ts b/backend/services/identity-service/identity-service/src/config/database.config.ts new file mode 100644 index 00000000..3cc9c86d --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/database.config.ts @@ -0,0 +1,3 @@ +export const databaseConfig = () => ({ + url: process.env.DATABASE_URL, +}); diff --git a/backend/services/identity-service/identity-service/src/config/index.ts b/backend/services/identity-service/identity-service/src/config/index.ts new file mode 100644 index 00000000..d9c0f6ae --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/index.ts @@ -0,0 +1,36 @@ +export const appConfig = () => ({ + port: parseInt(process.env.APP_PORT || '3000', 10), + env: process.env.APP_ENV || 'development', +}); + +export const databaseConfig = () => ({ + url: process.env.DATABASE_URL, +}); + +export const jwtConfig = () => ({ + secret: process.env.JWT_SECRET || 'default-secret', + accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', +}); + +export const redisConfig = () => ({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, + db: parseInt(process.env.REDIS_DB || '0', 10), +}); + +export const kafkaConfig = () => ({ + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + clientId: process.env.KAFKA_CLIENT_ID || 'identity-service', + groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group', +}); + +export const smsConfig = () => ({ + apiUrl: process.env.SMS_API_URL || '', + apiKey: process.env.SMS_API_KEY || '', +}); + +export const walletConfig = () => ({ + encryptionSalt: process.env.WALLET_ENCRYPTION_SALT || 'rwa-wallet-salt', +}); diff --git a/backend/services/identity-service/identity-service/src/config/jwt.config.ts b/backend/services/identity-service/identity-service/src/config/jwt.config.ts new file mode 100644 index 00000000..cddfa983 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/jwt.config.ts @@ -0,0 +1,5 @@ +export const jwtConfig = () => ({ + secret: process.env.JWT_SECRET || 'default-secret', + accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', +}); diff --git a/backend/services/identity-service/identity-service/src/config/kafka.config.ts b/backend/services/identity-service/identity-service/src/config/kafka.config.ts new file mode 100644 index 00000000..5a32f93c --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/kafka.config.ts @@ -0,0 +1,5 @@ +export const kafkaConfig = () => ({ + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + clientId: process.env.KAFKA_CLIENT_ID || 'identity-service', + groupId: process.env.KAFKA_GROUP_ID || 'identity-service-group', +}); diff --git a/backend/services/identity-service/identity-service/src/config/redis.config.ts b/backend/services/identity-service/identity-service/src/config/redis.config.ts new file mode 100644 index 00000000..6178285c --- /dev/null +++ b/backend/services/identity-service/identity-service/src/config/redis.config.ts @@ -0,0 +1,6 @@ +export const redisConfig = () => ({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, + db: parseInt(process.env.REDIS_DB || '0', 10), +}); diff --git a/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts new file mode 100644 index 00000000..6da2fa17 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.aggregate.ts @@ -0,0 +1,347 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; +import { + UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode, + DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus, +} from '@/domain/value-objects'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { + DomainEvent, UserAccountAutoCreatedEvent, UserAccountCreatedEvent, + DeviceAddedEvent, DeviceRemovedEvent, PhoneNumberBoundEvent, + WalletAddressBoundEvent, MultipleWalletAddressesBoundEvent, + KYCSubmittedEvent, KYCVerifiedEvent, KYCRejectedEvent, + UserLocationUpdatedEvent, UserAccountFrozenEvent, UserAccountDeactivatedEvent, +} from '@/domain/events'; + +export class UserAccount { + private readonly _userId: UserId; + private readonly _accountSequence: AccountSequence; + private _devices: Map; + private _phoneNumber: PhoneNumber | null; + private _nickname: string; + private _avatarUrl: string | null; + private readonly _inviterSequence: AccountSequence | null; + private readonly _referralCode: ReferralCode; + private _province: ProvinceCode; + private _city: CityCode; + private _address: string | null; + private _walletAddresses: Map; + private _kycInfo: KYCInfo | null; + private _kycStatus: KYCStatus; + private _status: AccountStatus; + private readonly _registeredAt: Date; + private _lastLoginAt: Date | null; + private _updatedAt: Date; + private _domainEvents: DomainEvent[] = []; + + // Getters + get userId(): UserId { return this._userId; } + get accountSequence(): AccountSequence { return this._accountSequence; } + get phoneNumber(): PhoneNumber | null { return this._phoneNumber; } + get nickname(): string { return this._nickname; } + get avatarUrl(): string | null { return this._avatarUrl; } + get inviterSequence(): AccountSequence | null { return this._inviterSequence; } + get referralCode(): ReferralCode { return this._referralCode; } + get province(): ProvinceCode { return this._province; } + get city(): CityCode { return this._city; } + get addressDetail(): string | null { return this._address; } + get kycInfo(): KYCInfo | null { return this._kycInfo; } + get kycStatus(): KYCStatus { return this._kycStatus; } + get status(): AccountStatus { return this._status; } + get registeredAt(): Date { return this._registeredAt; } + get lastLoginAt(): Date | null { return this._lastLoginAt; } + get updatedAt(): Date { return this._updatedAt; } + get isActive(): boolean { return this._status === AccountStatus.ACTIVE; } + get isKYCVerified(): boolean { return this._kycStatus === KYCStatus.VERIFIED; } + get domainEvents(): DomainEvent[] { return [...this._domainEvents]; } + + private constructor( + userId: UserId, accountSequence: AccountSequence, devices: Map, + phoneNumber: PhoneNumber | null, nickname: string, avatarUrl: string | null, + inviterSequence: AccountSequence | null, referralCode: ReferralCode, + province: ProvinceCode, city: CityCode, address: string | null, + walletAddresses: Map, kycInfo: KYCInfo | null, + kycStatus: KYCStatus, status: AccountStatus, registeredAt: Date, + lastLoginAt: Date | null, updatedAt: Date, + ) { + this._userId = userId; + this._accountSequence = accountSequence; + this._devices = devices; + this._phoneNumber = phoneNumber; + this._nickname = nickname; + this._avatarUrl = avatarUrl; + this._inviterSequence = inviterSequence; + this._referralCode = referralCode; + this._province = province; + this._city = city; + this._address = address; + this._walletAddresses = walletAddresses; + this._kycInfo = kycInfo; + this._kycStatus = kycStatus; + this._status = status; + this._registeredAt = registeredAt; + this._lastLoginAt = lastLoginAt; + this._updatedAt = updatedAt; + } + + static createAutomatic(params: { + accountSequence: AccountSequence; + initialDeviceId: string; + deviceName?: string; + inviterSequence: AccountSequence | null; + province: ProvinceCode; + city: CityCode; + }): UserAccount { + const devices = new Map(); + devices.set(params.initialDeviceId, new DeviceInfo( + params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), + )); + + const account = new UserAccount( + UserId.generate(), params.accountSequence, devices, null, + `用户${params.accountSequence.value}`, null, params.inviterSequence, + ReferralCode.generate(), params.province, params.city, null, + new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE, + new Date(), null, new Date(), + ); + + account.addDomainEvent(new UserAccountAutoCreatedEvent({ + userId: account.userId.value, + accountSequence: params.accountSequence.value, + initialDeviceId: params.initialDeviceId, + inviterSequence: params.inviterSequence?.value || null, + province: params.province.value, + city: params.city.value, + registeredAt: account._registeredAt, + })); + + return account; + } + + static create(params: { + accountSequence: AccountSequence; + phoneNumber: PhoneNumber; + initialDeviceId: string; + deviceName?: string; + inviterSequence: AccountSequence | null; + province: ProvinceCode; + city: CityCode; + }): UserAccount { + const devices = new Map(); + devices.set(params.initialDeviceId, new DeviceInfo( + params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), + )); + + const account = new UserAccount( + UserId.generate(), params.accountSequence, devices, params.phoneNumber, + `用户${params.accountSequence.value}`, null, params.inviterSequence, + ReferralCode.generate(), params.province, params.city, null, + new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE, + new Date(), null, new Date(), + ); + + account.addDomainEvent(new UserAccountCreatedEvent({ + userId: account.userId.value, + accountSequence: params.accountSequence.value, + phoneNumber: params.phoneNumber.value, + initialDeviceId: params.initialDeviceId, + inviterSequence: params.inviterSequence?.value || null, + province: params.province.value, + city: params.city.value, + registeredAt: account._registeredAt, + })); + + return account; + } + + static reconstruct(params: { + userId: string; accountSequence: number; devices: DeviceInfo[]; + phoneNumber: string | null; nickname: string; avatarUrl: string | null; + inviterSequence: number | null; referralCode: string; + province: string; city: string; address: string | null; + walletAddresses: WalletAddress[]; kycInfo: KYCInfo | null; + kycStatus: KYCStatus; status: AccountStatus; + registeredAt: Date; lastLoginAt: Date | null; updatedAt: Date; + }): UserAccount { + const deviceMap = new Map(); + params.devices.forEach(d => deviceMap.set(d.deviceId, d)); + + const walletMap = new Map(); + params.walletAddresses.forEach(w => walletMap.set(w.chainType, w)); + + return new UserAccount( + UserId.create(params.userId), + AccountSequence.create(params.accountSequence), + deviceMap, + params.phoneNumber ? PhoneNumber.create(params.phoneNumber) : null, + params.nickname, + params.avatarUrl, + params.inviterSequence ? AccountSequence.create(params.inviterSequence) : null, + ReferralCode.create(params.referralCode), + ProvinceCode.create(params.province), + CityCode.create(params.city), + params.address, + walletMap, + params.kycInfo, + params.kycStatus, + params.status, + params.registeredAt, + params.lastLoginAt, + params.updatedAt, + ); + } + + addDevice(deviceId: string, deviceName?: string): void { + this.ensureActive(); + if (this._devices.size >= 5 && !this._devices.has(deviceId)) { + throw new DomainError('最多允许5个设备同时登录'); + } + if (this._devices.has(deviceId)) { + this._devices.get(deviceId)!.updateActivity(); + } else { + this._devices.set(deviceId, new DeviceInfo(deviceId, deviceName || '未命名设备', new Date(), new Date())); + this.addDomainEvent(new DeviceAddedEvent({ + userId: this.userId.value, + accountSequence: this.accountSequence.value, + deviceId, + deviceName: deviceName || '未命名设备', + })); + } + this._updatedAt = new Date(); + } + + removeDevice(deviceId: string): void { + this.ensureActive(); + if (!this._devices.has(deviceId)) throw new DomainError('设备不存在'); + if (this._devices.size <= 1) throw new DomainError('至少保留一个设备'); + this._devices.delete(deviceId); + this._updatedAt = new Date(); + this.addDomainEvent(new DeviceRemovedEvent({ userId: this.userId.value, deviceId })); + } + + isDeviceAuthorized(deviceId: string): boolean { + return this._devices.has(deviceId); + } + + getAllDevices(): DeviceInfo[] { + return Array.from(this._devices.values()); + } + + updateProfile(params: { nickname?: string; avatarUrl?: string; address?: string }): void { + this.ensureActive(); + if (params.nickname) this._nickname = params.nickname; + if (params.avatarUrl !== undefined) this._avatarUrl = params.avatarUrl; + if (params.address !== undefined) this._address = params.address; + this._updatedAt = new Date(); + } + + updateLocation(province: ProvinceCode, city: CityCode): void { + this.ensureActive(); + this._province = province; + this._city = city; + this._updatedAt = new Date(); + this.addDomainEvent(new UserLocationUpdatedEvent({ + userId: this.userId.value, province: province.value, city: city.value, + })); + } + + bindPhoneNumber(phoneNumber: PhoneNumber): void { + this.ensureActive(); + if (this._phoneNumber) throw new DomainError('已绑定手机号,不可重复绑定'); + this._phoneNumber = phoneNumber; + this._updatedAt = new Date(); + this.addDomainEvent(new PhoneNumberBoundEvent({ userId: this.userId.value, phoneNumber: phoneNumber.value })); + } + + bindWalletAddress(chainType: ChainType, address: string): void { + this.ensureActive(); + if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`); + const walletAddress = WalletAddress.create({ userId: this.userId, chainType, address }); + this._walletAddresses.set(chainType, walletAddress); + this._updatedAt = new Date(); + this.addDomainEvent(new WalletAddressBoundEvent({ userId: this.userId.value, chainType, address })); + } + + bindMultipleWalletAddresses(wallets: Map): void { + this.ensureActive(); + for (const [chainType, wallet] of wallets) { + if (this._walletAddresses.has(chainType)) throw new DomainError(`已绑定${chainType}地址`); + this._walletAddresses.set(chainType, wallet); + } + this._updatedAt = new Date(); + this.addDomainEvent(new MultipleWalletAddressesBoundEvent({ + userId: this.userId.value, + addresses: Array.from(wallets.entries()).map(([chainType, wallet]) => ({ chainType, address: wallet.address })), + })); + } + + submitKYC(kycInfo: KYCInfo): void { + this.ensureActive(); + if (this._kycStatus === KYCStatus.VERIFIED) throw new DomainError('已通过KYC认证,不可重复提交'); + this._kycInfo = kycInfo; + this._kycStatus = KYCStatus.PENDING; + this._updatedAt = new Date(); + this.addDomainEvent(new KYCSubmittedEvent({ + userId: this.userId.value, realName: kycInfo.realName, idCardNumber: kycInfo.idCardNumber, + })); + } + + approveKYC(): void { + if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能通过KYC'); + this._kycStatus = KYCStatus.VERIFIED; + this._updatedAt = new Date(); + this.addDomainEvent(new KYCVerifiedEvent({ userId: this.userId.value, verifiedAt: new Date() })); + } + + rejectKYC(reason: string): void { + if (this._kycStatus !== KYCStatus.PENDING) throw new DomainError('只有待审核状态才能拒绝KYC'); + this._kycStatus = KYCStatus.REJECTED; + this._updatedAt = new Date(); + this.addDomainEvent(new KYCRejectedEvent({ userId: this.userId.value, reason })); + } + + recordLogin(): void { + this.ensureActive(); + this._lastLoginAt = new Date(); + this._updatedAt = new Date(); + } + + freeze(reason: string): void { + if (this._status === AccountStatus.FROZEN) throw new DomainError('账户已冻结'); + this._status = AccountStatus.FROZEN; + this._updatedAt = new Date(); + this.addDomainEvent(new UserAccountFrozenEvent({ userId: this.userId.value, reason })); + } + + unfreeze(): void { + if (this._status !== AccountStatus.FROZEN) throw new DomainError('账户未冻结'); + this._status = AccountStatus.ACTIVE; + this._updatedAt = new Date(); + } + + deactivate(): void { + if (this._status === AccountStatus.DEACTIVATED) throw new DomainError('账户已注销'); + this._status = AccountStatus.DEACTIVATED; + this._updatedAt = new Date(); + this.addDomainEvent(new UserAccountDeactivatedEvent({ userId: this.userId.value, deactivatedAt: new Date() })); + } + + getWalletAddress(chainType: ChainType): WalletAddress | null { + return this._walletAddresses.get(chainType) || null; + } + + getAllWalletAddresses(): WalletAddress[] { + return Array.from(this._walletAddresses.values()); + } + + private ensureActive(): void { + if (this._status !== AccountStatus.ACTIVE) throw new DomainError('账户已冻结或注销'); + } + + private addDomainEvent(event: DomainEvent): void { + this._domainEvents.push(event); + } + + clearDomainEvents(): void { + this._domainEvents = []; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.factory.ts b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.factory.ts new file mode 100644 index 00000000..b911b407 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.factory.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { UserAccount } from './user-account.aggregate'; +import { AccountSequence, PhoneNumber, ProvinceCode, CityCode } from '@/domain/value-objects'; + +@Injectable() +export class UserAccountFactory { + createAutomatic(params: { + accountSequence: AccountSequence; + initialDeviceId: string; + deviceName?: string; + inviterSequence: AccountSequence | null; + province: ProvinceCode; + city: CityCode; + }): UserAccount { + return UserAccount.createAutomatic(params); + } + + create(params: { + accountSequence: AccountSequence; + phoneNumber: PhoneNumber; + initialDeviceId: string; + deviceName?: string; + inviterSequence: AccountSequence | null; + province: ProvinceCode; + city: CityCode; + }): UserAccount { + return UserAccount.create(params); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.spec.ts b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.spec.ts new file mode 100644 index 00000000..53f739fd --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/aggregates/user-account/user-account.spec.ts @@ -0,0 +1,79 @@ +import { UserAccount } from './user-account.aggregate'; +import { AccountSequence, ProvinceCode, CityCode } from '@/domain/value-objects'; +import { DomainError } from '@/shared/exceptions/domain.exception'; + +describe('UserAccount', () => { + const createTestAccount = () => { + return UserAccount.createAutomatic({ + accountSequence: AccountSequence.create(1), + initialDeviceId: 'device-001', + deviceName: 'Test Device', + inviterSequence: null, + province: ProvinceCode.create('110000'), + city: CityCode.create('110100'), + }); + }; + + describe('createAutomatic', () => { + it('should create account with default values', () => { + const account = createTestAccount(); + expect(account.accountSequence.value).toBe(1); + expect(account.nickname).toBe('用户1'); + expect(account.isActive).toBe(true); + expect(account.phoneNumber).toBeNull(); + }); + + it('should add initial device', () => { + const account = createTestAccount(); + expect(account.isDeviceAuthorized('device-001')).toBe(true); + expect(account.getAllDevices()).toHaveLength(1); + }); + }); + + describe('addDevice', () => { + it('should add new device', () => { + const account = createTestAccount(); + account.addDevice('device-002', 'New Device'); + expect(account.getAllDevices()).toHaveLength(2); + }); + + it('should throw error when exceeding device limit', () => { + const account = createTestAccount(); + account.addDevice('device-002'); + account.addDevice('device-003'); + account.addDevice('device-004'); + account.addDevice('device-005'); + + expect(() => account.addDevice('device-006')).toThrow(DomainError); + }); + }); + + describe('removeDevice', () => { + it('should remove existing device', () => { + const account = createTestAccount(); + account.addDevice('device-002'); + account.removeDevice('device-002'); + expect(account.getAllDevices()).toHaveLength(1); + }); + + it('should not remove last device', () => { + const account = createTestAccount(); + expect(() => account.removeDevice('device-001')).toThrow(DomainError); + }); + }); + + describe('freeze/unfreeze', () => { + it('should freeze active account', () => { + const account = createTestAccount(); + account.freeze('Test reason'); + expect(account.isActive).toBe(false); + }); + + it('should unfreeze frozen account', () => { + const account = createTestAccount(); + account.freeze('Test reason'); + account.unfreeze(); + expect(account.isActive).toBe(true); + }); + }); +}); diff --git a/backend/services/identity-service/identity-service/src/domain/domain.module.ts b/backend/services/identity-service/identity-service/src/domain/domain.module.ts new file mode 100644 index 00000000..fcf40c9b --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/domain.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from './services'; +import { UserAccountFactory } from './aggregates/user-account/user-account.factory'; +import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface'; +import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl'; +import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; + +@Module({ + imports: [InfrastructureModule], + providers: [ + { provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl }, + AccountSequenceGeneratorService, + UserValidatorService, + WalletGeneratorService, + UserAccountFactory, + ], + exports: [ + USER_ACCOUNT_REPOSITORY, + AccountSequenceGeneratorService, + UserValidatorService, + WalletGeneratorService, + UserAccountFactory, + ], +}) +export class DomainModule {} diff --git a/backend/services/identity-service/identity-service/src/domain/entities/wallet-address.entity.ts b/backend/services/identity-service/identity-service/src/domain/entities/wallet-address.entity.ts new file mode 100644 index 00000000..225498f8 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/entities/wallet-address.entity.ts @@ -0,0 +1,167 @@ +import { HDKey } from '@scure/bip32'; +import { createHash } from 'crypto'; +import { bech32 } from 'bech32'; +import { Wallet } from 'ethers'; +import { DomainError } from '@/shared/exceptions/domain.exception'; +import { + AddressId, + UserId, + ChainType, + CHAIN_CONFIG, + AddressStatus, + Mnemonic, + MnemonicEncryption, +} from '@/domain/value-objects'; + +export class WalletAddress { + private readonly _addressId: AddressId; + private readonly _userId: UserId; + private readonly _chainType: ChainType; + private readonly _address: string; + private readonly _encryptedMnemonic: string; + private _status: AddressStatus; + private readonly _boundAt: Date; + + get addressId(): AddressId { return this._addressId; } + get userId(): UserId { return this._userId; } + get chainType(): ChainType { return this._chainType; } + get address(): string { return this._address; } + get encryptedMnemonic(): string { return this._encryptedMnemonic; } + get status(): AddressStatus { return this._status; } + get boundAt(): Date { return this._boundAt; } + + private constructor( + addressId: AddressId, + userId: UserId, + chainType: ChainType, + address: string, + encryptedMnemonic: string, + status: AddressStatus, + boundAt: Date, + ) { + this._addressId = addressId; + this._userId = userId; + this._chainType = chainType; + this._address = address; + this._encryptedMnemonic = encryptedMnemonic; + this._status = status; + this._boundAt = boundAt; + } + + static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress { + if (!this.validateAddress(params.chainType, params.address)) { + throw new DomainError(`${params.chainType}地址格式错误`); + } + return new WalletAddress( + AddressId.generate(), + params.userId, + params.chainType, + params.address, + '', + AddressStatus.ACTIVE, + new Date(), + ); + } + + static createFromMnemonic(params: { + userId: UserId; + chainType: ChainType; + mnemonic: Mnemonic; + encryptionKey: string; + }): WalletAddress { + const address = this.deriveAddress(params.chainType, params.mnemonic); + const encryptedMnemonic = MnemonicEncryption.encrypt(params.mnemonic.value, params.encryptionKey); + return new WalletAddress( + AddressId.generate(), + params.userId, + params.chainType, + address, + encryptedMnemonic, + AddressStatus.ACTIVE, + new Date(), + ); + } + + static reconstruct(params: { + addressId: string; + userId: string; + chainType: ChainType; + address: string; + encryptedMnemonic: string; + status: AddressStatus; + boundAt: Date; + }): WalletAddress { + return new WalletAddress( + AddressId.create(params.addressId), + UserId.create(params.userId), + params.chainType, + params.address, + params.encryptedMnemonic, + params.status, + params.boundAt, + ); + } + + disable(): void { + this._status = AddressStatus.DISABLED; + } + + enable(): void { + this._status = AddressStatus.ACTIVE; + } + + decryptMnemonic(encryptionKey: string): Mnemonic { + if (!this._encryptedMnemonic) { + throw new DomainError('该地址没有加密助记词'); + } + const mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey); + return Mnemonic.create(mnemonicStr); + } + + private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string { + const seed = mnemonic.toSeed(); + const config = CHAIN_CONFIG[chainType]; + + switch (chainType) { + case ChainType.KAVA: + case ChainType.DST: + return this.deriveCosmosAddress(Buffer.from(seed), config.derivationPath, config.prefix); + case ChainType.BSC: + return this.deriveEVMAddress(Buffer.from(seed), config.derivationPath); + default: + throw new DomainError(`不支持的链类型: ${chainType}`); + } + } + + private static deriveCosmosAddress(seed: Buffer, path: string, prefix: string): string { + const hdkey = HDKey.fromMasterSeed(seed); + const childKey = hdkey.derive(path); + if (!childKey.publicKey) throw new DomainError('无法派生公钥'); + + const hash = createHash('sha256').update(childKey.publicKey).digest(); + const addressHash = createHash('ripemd160').update(hash).digest(); + const words = bech32.toWords(addressHash); + return bech32.encode(prefix, words); + } + + private static deriveEVMAddress(seed: Buffer, path: string): string { + const hdkey = HDKey.fromMasterSeed(seed); + const childKey = hdkey.derive(path); + if (!childKey.privateKey) throw new DomainError('无法派生私钥'); + + const wallet = new Wallet(Buffer.from(childKey.privateKey).toString('hex')); + return wallet.address; + } + + private static validateAddress(chainType: ChainType, address: string): boolean { + switch (chainType) { + case ChainType.KAVA: + case ChainType.DST: + return /^(kava|dst)1[a-z0-9]{38}$/.test(address); + case ChainType.BSC: + return /^0x[a-fA-F0-9]{40}$/.test(address); + default: + return false; + } + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/events/device-added.event.ts b/backend/services/identity-service/identity-service/src/domain/events/device-added.event.ts new file mode 100644 index 00000000..62dc40f3 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/events/device-added.event.ts @@ -0,0 +1,18 @@ +import { DomainEvent } from './index'; + +export class DeviceAddedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: number; + deviceId: string; + deviceName: string; + }, + ) { + super(); + } + + get eventType(): string { + return 'DeviceAdded'; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/events/index.ts b/backend/services/identity-service/identity-service/src/domain/events/index.ts new file mode 100644 index 00000000..8234990e --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/events/index.ts @@ -0,0 +1,174 @@ +export abstract class DomainEvent { + public readonly occurredAt: Date; + public readonly eventId: string; + + constructor() { + this.occurredAt = new Date(); + this.eventId = crypto.randomUUID(); + } + + abstract get eventType(): string; +} + +export class UserAccountAutoCreatedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: number; + initialDeviceId: string; + inviterSequence: number | null; + province: string; + city: string; + registeredAt: Date; + }, + ) { + super(); + } + + get eventType(): string { + return 'UserAccountAutoCreated'; + } +} + +export class UserAccountCreatedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: number; + phoneNumber: string; + initialDeviceId: string; + inviterSequence: number | null; + province: string; + city: string; + registeredAt: Date; + }, + ) { + super(); + } + + get eventType(): string { + return 'UserAccountCreated'; + } +} + +export class DeviceAddedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: number; + deviceId: string; + deviceName: string; + }, + ) { + super(); + } + + get eventType(): string { + return 'DeviceAdded'; + } +} + +export class DeviceRemovedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; deviceId: string }) { + super(); + } + + get eventType(): string { + return 'DeviceRemoved'; + } +} + +export class PhoneNumberBoundEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; phoneNumber: string }) { + super(); + } + + get eventType(): string { + return 'PhoneNumberBound'; + } +} + +export class WalletAddressBoundEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; chainType: string; address: string }) { + super(); + } + + get eventType(): string { + return 'WalletAddressBound'; + } +} + +export class MultipleWalletAddressesBoundEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + addresses: Array<{ chainType: string; address: string }>; + }, + ) { + super(); + } + + get eventType(): string { + return 'MultipleWalletAddressesBound'; + } +} + +export class KYCSubmittedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; realName: string; idCardNumber: string }) { + super(); + } + + get eventType(): string { + return 'KYCSubmitted'; + } +} + +export class KYCVerifiedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; verifiedAt: Date }) { + super(); + } + + get eventType(): string { + return 'KYCVerified'; + } +} + +export class KYCRejectedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; reason: string }) { + super(); + } + + get eventType(): string { + return 'KYCRejected'; + } +} + +export class UserLocationUpdatedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; province: string; city: string }) { + super(); + } + + get eventType(): string { + return 'UserLocationUpdated'; + } +} + +export class UserAccountFrozenEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; reason: string }) { + super(); + } + + get eventType(): string { + return 'UserAccountFrozen'; + } +} + +export class UserAccountDeactivatedEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; deactivatedAt: Date }) { + super(); + } + + get eventType(): string { + return 'UserAccountDeactivated'; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/events/phone-bound.event.ts b/backend/services/identity-service/identity-service/src/domain/events/phone-bound.event.ts new file mode 100644 index 00000000..78aa8a19 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/events/phone-bound.event.ts @@ -0,0 +1,11 @@ +import { DomainEvent } from './index'; + +export class PhoneNumberBoundEvent extends DomainEvent { + constructor(public readonly payload: { userId: string; phoneNumber: string }) { + super(); + } + + get eventType(): string { + return 'PhoneNumberBound'; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/events/user-account-created.event.ts b/backend/services/identity-service/identity-service/src/domain/events/user-account-created.event.ts new file mode 100644 index 00000000..dcd1b390 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/events/user-account-created.event.ts @@ -0,0 +1,22 @@ +import { DomainEvent } from './index'; + +export class UserAccountCreatedEvent extends DomainEvent { + constructor( + public readonly payload: { + userId: string; + accountSequence: number; + phoneNumber: string; + initialDeviceId: string; + inviterSequence: number | null; + province: string; + city: string; + registeredAt: Date; + }, + ) { + super(); + } + + get eventType(): string { + return 'UserAccountCreated'; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/repositories/user-account.repository.interface.ts b/backend/services/identity-service/identity-service/src/domain/repositories/user-account.repository.interface.ts new file mode 100644 index 00000000..3584bb49 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/repositories/user-account.repository.interface.ts @@ -0,0 +1,30 @@ +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { + UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, AccountStatus, KYCStatus, +} from '@/domain/value-objects'; + +export interface Pagination { + page: number; + limit: number; +} + +export interface UserAccountRepository { + save(account: UserAccount): Promise; + saveWallets(userId: UserId, wallets: WalletAddress[]): Promise; + findById(userId: UserId): Promise; + findByAccountSequence(sequence: AccountSequence): Promise; + findByDeviceId(deviceId: string): Promise; + findByPhoneNumber(phoneNumber: PhoneNumber): Promise; + findByReferralCode(referralCode: ReferralCode): Promise; + findByWalletAddress(chainType: ChainType, address: string): Promise; + getMaxAccountSequence(): Promise; + getNextAccountSequence(): Promise; + findUsers( + filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string }, + pagination?: Pagination, + ): Promise; + countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise; +} + +export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY'); diff --git a/backend/services/identity-service/identity-service/src/domain/services/account-sequence-generator.service.ts b/backend/services/identity-service/identity-service/src/domain/services/account-sequence-generator.service.ts new file mode 100644 index 00000000..875d3e3e --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/services/account-sequence-generator.service.ts @@ -0,0 +1,15 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { AccountSequence } from '@/domain/value-objects'; + +@Injectable() +export class AccountSequenceGeneratorService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly repository: UserAccountRepository, + ) {} + + async generateNext(): Promise { + return this.repository.getNextAccountSequence(); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/services/index.ts b/backend/services/identity-service/identity-service/src/domain/services/index.ts new file mode 100644 index 00000000..52dc56e3 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/services/index.ts @@ -0,0 +1,121 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { + AccountSequence, PhoneNumber, ReferralCode, ChainType, Mnemonic, UserId, +} from '@/domain/value-objects'; + +// ============ ValidationResult ============ +export class ValidationResult { + private constructor( + public readonly isValid: boolean, + public readonly errorMessage: string | null, + ) {} + + static success(): ValidationResult { + return new ValidationResult(true, null); + } + + static failure(message: string): ValidationResult { + return new ValidationResult(false, message); + } +} + +// ============ AccountSequenceGeneratorService ============ +@Injectable() +export class AccountSequenceGeneratorService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly repository: UserAccountRepository, + ) {} + + async generateNext(): Promise { + return this.repository.getNextAccountSequence(); + } +} + +// ============ UserValidatorService ============ +@Injectable() +export class UserValidatorService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly repository: UserAccountRepository, + ) {} + + async validatePhoneNumber(phoneNumber: PhoneNumber): Promise { + const existing = await this.repository.findByPhoneNumber(phoneNumber); + if (existing) return ValidationResult.failure('该手机号已注册'); + return ValidationResult.success(); + } + + async validateDeviceId(deviceId: string): Promise { + const existing = await this.repository.findByDeviceId(deviceId); + if (existing) return ValidationResult.failure('该设备已创建账户'); + return ValidationResult.success(); + } + + async validateReferralCode(referralCode: ReferralCode): Promise { + const inviter = await this.repository.findByReferralCode(referralCode); + if (!inviter) return ValidationResult.failure('推荐码不存在'); + if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); + return ValidationResult.success(); + } + + async validateWalletAddress(chainType: ChainType, address: string): Promise { + const existing = await this.repository.findByWalletAddress(chainType, address); + if (existing) return ValidationResult.failure('该地址已被其他账户绑定'); + return ValidationResult.success(); + } +} + +// ============ WalletGeneratorService ============ +@Injectable() +export class WalletGeneratorService { + generateWalletSystem(params: { userId: UserId; deviceId: string }): { + mnemonic: Mnemonic; + wallets: Map; + } { + const mnemonic = Mnemonic.generate(); + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return { mnemonic, wallets }; + } + + recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map { + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic: params.mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return wallets; + } + + private deriveEncryptionKey(deviceId: string, userId: string): string { + const input = `${deviceId}:${userId}`; + return createHash('sha256').update(input).digest('hex'); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/services/user-validator.service.ts b/backend/services/identity-service/identity-service/src/domain/services/user-validator.service.ts new file mode 100644 index 00000000..cc6f2242 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/services/user-validator.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects'; + +export class ValidationResult { + private constructor( + public readonly isValid: boolean, + public readonly errorMessage: string | null, + ) {} + + static success(): ValidationResult { + return new ValidationResult(true, null); + } + + static failure(message: string): ValidationResult { + return new ValidationResult(false, message); + } +} + +@Injectable() +export class UserValidatorService { + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly repository: UserAccountRepository, + ) {} + + async validatePhoneNumber(phoneNumber: PhoneNumber): Promise { + const existing = await this.repository.findByPhoneNumber(phoneNumber); + if (existing) return ValidationResult.failure('该手机号已注册'); + return ValidationResult.success(); + } + + async validateDeviceId(deviceId: string): Promise { + const existing = await this.repository.findByDeviceId(deviceId); + if (existing) return ValidationResult.failure('该设备已创建账户'); + return ValidationResult.success(); + } + + async validateReferralCode(referralCode: ReferralCode): Promise { + const inviter = await this.repository.findByReferralCode(referralCode); + if (!inviter) return ValidationResult.failure('推荐码不存在'); + if (!inviter.isActive) return ValidationResult.failure('推荐人账户已冻结或注销'); + return ValidationResult.success(); + } + + async validateWalletAddress(chainType: ChainType, address: string): Promise { + const existing = await this.repository.findByWalletAddress(chainType, address); + if (existing) return ValidationResult.failure('该地址已被其他账户绑定'); + return ValidationResult.success(); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/services/wallet-generator.service.ts b/backend/services/identity-service/identity-service/src/domain/services/wallet-generator.service.ts new file mode 100644 index 00000000..5ee9af64 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/services/wallet-generator.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { ChainType, Mnemonic, UserId } from '@/domain/value-objects'; + +@Injectable() +export class WalletGeneratorService { + generateWalletSystem(params: { userId: UserId; deviceId: string }): { + mnemonic: Mnemonic; + wallets: Map; + } { + const mnemonic = Mnemonic.generate(); + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return { mnemonic, wallets }; + } + + recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map { + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic: params.mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return wallets; + } + + private deriveEncryptionKey(deviceId: string, userId: string): string { + const input = `${deviceId}:${userId}`; + return createHash('sha256').update(input).digest('hex'); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/account-sequence.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/account-sequence.vo.ts new file mode 100644 index 00000000..f12f6d35 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/account-sequence.vo.ts @@ -0,0 +1,19 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; + +export class AccountSequence { + constructor(public readonly value: number) { + if (value <= 0) throw new DomainError('账户序列号必须大于0'); + } + + static create(value: number): AccountSequence { + return new AccountSequence(value); + } + + static next(current: AccountSequence): AccountSequence { + return new AccountSequence(current.value + 1); + } + + equals(other: AccountSequence): boolean { + return this.value === other.value; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/device-info.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/device-info.vo.ts new file mode 100644 index 00000000..f1b571b8 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/device-info.vo.ts @@ -0,0 +1,20 @@ +export class DeviceInfo { + private _lastActiveAt: Date; + + constructor( + public readonly deviceId: string, + public readonly deviceName: string, + public readonly addedAt: Date, + lastActiveAt: Date, + ) { + this._lastActiveAt = lastActiveAt; + } + + get lastActiveAt(): Date { + return this._lastActiveAt; + } + + updateActivity(): void { + this._lastActiveAt = new Date(); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/index.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/index.ts new file mode 100644 index 00000000..a3800b80 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/index.ts @@ -0,0 +1,262 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; +import { createHash, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; +import * as bip39 from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +// ============ UserId ============ +export class UserId { + constructor(public readonly value: string) { + if (!value) throw new DomainError('UserId不能为空'); + } + + static generate(): UserId { + return new UserId(crypto.randomUUID()); + } + + static create(value: string): UserId { + return new UserId(value); + } + + equals(other: UserId): boolean { + return this.value === other.value; + } +} + +// ============ AccountSequence ============ +export class AccountSequence { + constructor(public readonly value: number) { + if (value <= 0) throw new DomainError('账户序列号必须大于0'); + } + + static create(value: number): AccountSequence { + return new AccountSequence(value); + } + + static next(current: AccountSequence): AccountSequence { + return new AccountSequence(current.value + 1); + } + + equals(other: AccountSequence): boolean { + return this.value === other.value; + } +} + +// ============ PhoneNumber ============ +export class PhoneNumber { + constructor(public readonly value: string) { + if (!/^1[3-9]\d{9}$/.test(value)) { + throw new DomainError('手机号格式错误'); + } + } + + static create(value: string): PhoneNumber { + return new PhoneNumber(value); + } + + equals(other: PhoneNumber): boolean { + return this.value === other.value; + } + + masked(): string { + return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); + } +} + +// ============ ReferralCode ============ +export class ReferralCode { + constructor(public readonly value: string) { + if (!/^[A-Z0-9]{6}$/.test(value)) { + throw new DomainError('推荐码格式错误'); + } + } + + static generate(): ReferralCode { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return new ReferralCode(code); + } + + static create(value: string): ReferralCode { + return new ReferralCode(value.toUpperCase()); + } + + equals(other: ReferralCode): boolean { + return this.value === other.value; + } +} + +// ============ ProvinceCode & CityCode ============ +export class ProvinceCode { + constructor(public readonly value: string) {} + + static create(value: string): ProvinceCode { + return new ProvinceCode(value || 'DEFAULT'); + } +} + +export class CityCode { + constructor(public readonly value: string) {} + + static create(value: string): CityCode { + return new CityCode(value || 'DEFAULT'); + } +} + +// ============ Mnemonic ============ +export class Mnemonic { + constructor(public readonly value: string) { + if (!bip39.validateMnemonic(value, wordlist)) { + throw new DomainError('助记词格式错误'); + } + } + + static generate(): Mnemonic { + const mnemonic = bip39.generateMnemonic(wordlist, 128); + return new Mnemonic(mnemonic); + } + + static create(value: string): Mnemonic { + return new Mnemonic(value); + } + + toSeed(): Uint8Array { + return bip39.mnemonicToSeedSync(this.value); + } + + getWords(): string[] { + return this.value.split(' '); + } + + equals(other: Mnemonic): boolean { + return this.value === other.value; + } +} + +// ============ DeviceInfo ============ +export class DeviceInfo { + private _lastActiveAt: Date; + + constructor( + public readonly deviceId: string, + public readonly deviceName: string, + public readonly addedAt: Date, + lastActiveAt: Date, + ) { + this._lastActiveAt = lastActiveAt; + } + + get lastActiveAt(): Date { + return this._lastActiveAt; + } + + updateActivity(): void { + this._lastActiveAt = new Date(); + } +} + +// ============ ChainType ============ +export enum ChainType { + KAVA = 'KAVA', + DST = 'DST', + BSC = 'BSC', +} + +export const CHAIN_CONFIG = { + [ChainType.KAVA]: { prefix: 'kava', derivationPath: "m/44'/459'/0'/0/0" }, + [ChainType.DST]: { prefix: 'dst', derivationPath: "m/44'/118'/0'/0/0" }, + [ChainType.BSC]: { prefix: '0x', derivationPath: "m/44'/60'/0'/0/0" }, +}; + +// ============ KYCInfo ============ +export class KYCInfo { + constructor( + public readonly realName: string, + public readonly idCardNumber: string, + public readonly idCardFrontUrl: string, + public readonly idCardBackUrl: string, + ) { + if (!realName || realName.length < 2) { + throw new DomainError('真实姓名不合法'); + } + if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) { + throw new DomainError('身份证号格式错误'); + } + } + + static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo { + return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl); + } + + maskedIdCardNumber(): string { + return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2'); + } +} + +// ============ Enums ============ +export enum KYCStatus { + NOT_VERIFIED = 'NOT_VERIFIED', + PENDING = 'PENDING', + VERIFIED = 'VERIFIED', + REJECTED = 'REJECTED', +} + +export enum AccountStatus { + ACTIVE = 'ACTIVE', + FROZEN = 'FROZEN', + DEACTIVATED = 'DEACTIVATED', +} + +export enum AddressStatus { + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', +} + +// ============ AddressId ============ +export class AddressId { + constructor(public readonly value: string) {} + + static generate(): AddressId { + return new AddressId(crypto.randomUUID()); + } + + static create(value: string): AddressId { + return new AddressId(value); + } +} + +// ============ MnemonicEncryption ============ +export class MnemonicEncryption { + static encrypt(mnemonic: string, key: string): string { + const derivedKey = this.deriveKey(key); + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-gcm', derivedKey, iv); + + let encrypted = cipher.update(mnemonic, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + return JSON.stringify({ + encrypted, + authTag: authTag.toString('hex'), + iv: iv.toString('hex'), + }); + } + + static decrypt(encryptedData: string, key: string): string { + const { encrypted, authTag, iv } = JSON.parse(encryptedData); + const derivedKey = this.deriveKey(key); + const decipher = createDecipheriv('aes-256-gcm', derivedKey, Buffer.from(iv, 'hex')); + decipher.setAuthTag(Buffer.from(authTag, 'hex')); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + private static deriveKey(password: string): Buffer { + return scryptSync(password, 'rwa-wallet-salt', 32); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/kyc-info.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/kyc-info.vo.ts new file mode 100644 index 00000000..2b1b14bb --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/kyc-info.vo.ts @@ -0,0 +1,25 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; + +export class KYCInfo { + constructor( + public readonly realName: string, + public readonly idCardNumber: string, + public readonly idCardFrontUrl: string, + public readonly idCardBackUrl: string, + ) { + if (!realName || realName.length < 2) { + throw new DomainError('真实姓名不合法'); + } + if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]$/.test(idCardNumber)) { + throw new DomainError('身份证号格式错误'); + } + } + + static create(params: { realName: string; idCardNumber: string; idCardFrontUrl: string; idCardBackUrl: string }): KYCInfo { + return new KYCInfo(params.realName, params.idCardNumber, params.idCardFrontUrl, params.idCardBackUrl); + } + + maskedIdCardNumber(): string { + return this.idCardNumber.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2'); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/mnemonic.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/mnemonic.vo.ts new file mode 100644 index 00000000..7740b289 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/mnemonic.vo.ts @@ -0,0 +1,32 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; +import * as bip39 from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +export class Mnemonic { + constructor(public readonly value: string) { + if (!bip39.validateMnemonic(value, wordlist)) { + throw new DomainError('助记词格式错误'); + } + } + + static generate(): Mnemonic { + const mnemonic = bip39.generateMnemonic(wordlist, 128); + return new Mnemonic(mnemonic); + } + + static create(value: string): Mnemonic { + return new Mnemonic(value); + } + + toSeed(): Uint8Array { + return bip39.mnemonicToSeedSync(this.value); + } + + getWords(): string[] { + return this.value.split(' '); + } + + equals(other: Mnemonic): boolean { + return this.value === other.value; + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/phone-number.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/phone-number.vo.ts new file mode 100644 index 00000000..97d18118 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/phone-number.vo.ts @@ -0,0 +1,21 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; + +export class PhoneNumber { + constructor(public readonly value: string) { + if (!/^1[3-9]\d{9}$/.test(value)) { + throw new DomainError('手机号格式错误'); + } + } + + static create(value: string): PhoneNumber { + return new PhoneNumber(value); + } + + equals(other: PhoneNumber): boolean { + return this.value === other.value; + } + + masked(): string { + return this.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); + } +} diff --git a/backend/services/identity-service/identity-service/src/domain/value-objects/referral-code.vo.ts b/backend/services/identity-service/identity-service/src/domain/value-objects/referral-code.vo.ts new file mode 100644 index 00000000..230963fe --- /dev/null +++ b/backend/services/identity-service/identity-service/src/domain/value-objects/referral-code.vo.ts @@ -0,0 +1,26 @@ +import { DomainError } from '@/shared/exceptions/domain.exception'; + +export class ReferralCode { + constructor(public readonly value: string) { + if (!/^[A-Z0-9]{6}$/.test(value)) { + throw new DomainError('推荐码格式错误'); + } + } + + static generate(): ReferralCode { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return new ReferralCode(code); + } + + static create(value: string): ReferralCode { + return new ReferralCode(value.toUpperCase()); + } + + equals(other: ReferralCode): boolean { + return this.value === other.value; + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/blockchain.module.ts b/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/blockchain.module.ts new file mode 100644 index 00000000..d33a9eee --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/blockchain.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { WalletGeneratorServiceImpl } from './wallet-generator.service.impl'; + +@Module({ + providers: [WalletGeneratorServiceImpl], + exports: [WalletGeneratorServiceImpl], +}) +export class BlockchainModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.impl.ts b/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.impl.ts new file mode 100644 index 00000000..f1d3d40a --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/external/blockchain/wallet-generator.service.impl.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { ChainType, Mnemonic, UserId } from '@/domain/value-objects'; + +@Injectable() +export class WalletGeneratorServiceImpl { + generateWalletSystem(params: { userId: UserId; deviceId: string }): { + mnemonic: Mnemonic; + wallets: Map; + } { + const mnemonic = Mnemonic.generate(); + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return { mnemonic, wallets }; + } + + recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map { + const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.value); + const wallets = new Map(); + const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC]; + + for (const chainType of chains) { + const wallet = WalletAddress.createFromMnemonic({ + userId: params.userId, + chainType, + mnemonic: params.mnemonic, + encryptionKey, + }); + wallets.set(chainType, wallet); + } + + return wallets; + } + + private deriveEncryptionKey(deviceId: string, userId: string): string { + const input = `${deviceId}:${userId}`; + return createHash('sha256').update(input).digest('hex'); + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.module.ts b/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.module.ts new file mode 100644 index 00000000..cc282dd4 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.service.ts b/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.service.ts new file mode 100644 index 00000000..5923a40b --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/external/sms/sms.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SmsService { + constructor(private readonly configService: ConfigService) {} + + async sendSms(phoneNumber: string, content: string): Promise { + const apiUrl = this.configService.get('SMS_API_URL'); + const apiKey = this.configService.get('SMS_API_KEY'); + + // 实际项目中调用SMS API + console.log(`[SMS] Sending to ${phoneNumber}: ${content}`); + + // 模拟发送成功 + return true; + } + + async sendVerificationCode(phoneNumber: string, code: string): Promise { + const content = `您的验证码是${code},5分钟内有效。`; + return this.sendSms(phoneNumber, content); + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/infrastructure.module.ts b/backend/services/identity-service/identity-service/src/infrastructure/infrastructure.module.ts new file mode 100644 index 00000000..4c53bcb2 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/infrastructure.module.ts @@ -0,0 +1,31 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaService } from './persistence/prisma/prisma.service'; +import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl'; +import { UserAccountMapper } from './persistence/mappers/user-account.mapper'; +import { RedisService } from './redis/redis.service'; +import { EventPublisherService } from './kafka/event-publisher.service'; +import { SmsService } from './external/sms/sms.service'; +import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl'; + +@Global() +@Module({ + providers: [ + PrismaService, + UserAccountRepositoryImpl, + UserAccountMapper, + RedisService, + EventPublisherService, + SmsService, + WalletGeneratorServiceImpl, + ], + exports: [ + PrismaService, + UserAccountRepositoryImpl, + UserAccountMapper, + RedisService, + EventPublisherService, + SmsService, + WalletGeneratorServiceImpl, + ], +}) +export class InfrastructureModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/kafka/event-publisher.service.ts b/backend/services/identity-service/identity-service/src/infrastructure/kafka/event-publisher.service.ts new file mode 100644 index 00000000..dcfd796a --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/kafka/event-publisher.service.ts @@ -0,0 +1,53 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kafka, Producer, Consumer, logLevel } from 'kafkajs'; +import { DomainEvent } from '@/domain/events'; + +@Injectable() +export class EventPublisherService implements OnModuleInit, OnModuleDestroy { + private kafka: Kafka; + private producer: Producer; + + constructor(private readonly configService: ConfigService) { + this.kafka = new Kafka({ + clientId: this.configService.get('KAFKA_CLIENT_ID', 'identity-service'), + brokers: (this.configService.get('KAFKA_BROKERS', 'localhost:9092')).split(','), + logLevel: logLevel.WARN, + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.producer.connect(); + } + + async onModuleDestroy() { + await this.producer.disconnect(); + } + + async publish(event: DomainEvent): Promise { + await this.producer.send({ + topic: `identity.${event.eventType}`, + messages: [ + { + key: event.eventId, + value: JSON.stringify({ + eventId: event.eventId, + eventType: event.eventType, + occurredAt: event.occurredAt.toISOString(), + payload: (event as any).payload, + }), + }, + ], + }); + } + + async publishAll(events: DomainEvent[]): Promise { + for (const event of events) { + await this.publish(event); + } + } +} + +@Injectable() +export class KafkaModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/kafka/kafka.module.ts b/backend/services/identity-service/identity-service/src/infrastructure/kafka/kafka.module.ts new file mode 100644 index 00000000..98309286 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/kafka/kafka.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EventPublisherService } from './event-publisher.service'; + +@Module({ + providers: [EventPublisherService], + exports: [EventPublisherService], +}) +export class KafkaModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-account.entity.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-account.entity.ts new file mode 100644 index 00000000..b1fb63ea --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-account.entity.ts @@ -0,0 +1,44 @@ +// Prisma Entity Types - 用于Mapper转换 +export interface UserAccountEntity { + userId: bigint; + accountSequence: bigint; + phoneNumber: string | null; + nickname: string; + avatarUrl: string | null; + inviterSequence: bigint | null; + referralCode: string; + provinceCode: string; + cityCode: string; + address: string | null; + kycStatus: string; + realName: string | null; + idCardNumber: string | null; + idCardFrontUrl: string | null; + idCardBackUrl: string | null; + kycVerifiedAt: Date | null; + status: string; + registeredAt: Date; + lastLoginAt: Date | null; + updatedAt: Date; + devices?: UserDeviceEntity[]; + walletAddresses?: WalletAddressEntity[]; +} + +export interface UserDeviceEntity { + id: bigint; + userId: bigint; + deviceId: string; + deviceName: string | null; + addedAt: Date; + lastActiveAt: Date; +} + +export interface WalletAddressEntity { + addressId: bigint; + userId: bigint; + chainType: string; + address: string; + encryptedMnemonic: string | null; + status: string; + boundAt: Date; +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-device.entity.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-device.entity.ts new file mode 100644 index 00000000..3d630d46 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/user-device.entity.ts @@ -0,0 +1,8 @@ +export interface UserDeviceEntity { + id: bigint; + userId: bigint; + deviceId: string; + deviceName: string | null; + addedAt: Date; + lastActiveAt: Date; +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts new file mode 100644 index 00000000..d4a5b402 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts @@ -0,0 +1,9 @@ +export interface WalletAddressEntity { + addressId: bigint; + userId: bigint; + chainType: string; + address: string; + encryptedMnemonic: string | null; + status: string; + boundAt: Date; +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/mappers/user-account.mapper.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/mappers/user-account.mapper.ts new file mode 100644 index 00000000..52882eb8 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/mappers/user-account.mapper.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects'; +import { UserAccountEntity } from '../entities/user-account.entity'; + +@Injectable() +export class UserAccountMapper { + toDomain(entity: UserAccountEntity): UserAccount { + const devices = (entity.devices || []).map( + (d) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt), + ); + + const wallets = (entity.walletAddresses || []).map((w) => + WalletAddress.reconstruct({ + addressId: w.addressId.toString(), + userId: w.userId.toString(), + chainType: w.chainType as ChainType, + address: w.address, + encryptedMnemonic: w.encryptedMnemonic || '', + status: w.status as AddressStatus, + boundAt: w.boundAt, + }), + ); + + const kycInfo = + entity.realName && entity.idCardNumber + ? KYCInfo.create({ + realName: entity.realName, + idCardNumber: entity.idCardNumber, + idCardFrontUrl: entity.idCardFrontUrl || '', + idCardBackUrl: entity.idCardBackUrl || '', + }) + : null; + + return UserAccount.reconstruct({ + userId: entity.userId.toString(), + accountSequence: Number(entity.accountSequence), + devices, + phoneNumber: entity.phoneNumber, + nickname: entity.nickname, + avatarUrl: entity.avatarUrl, + inviterSequence: entity.inviterSequence ? Number(entity.inviterSequence) : null, + referralCode: entity.referralCode, + province: entity.provinceCode, + city: entity.cityCode, + address: entity.address, + walletAddresses: wallets, + kycInfo, + kycStatus: entity.kycStatus as KYCStatus, + status: entity.status as AccountStatus, + registeredAt: entity.registeredAt, + lastLoginAt: entity.lastLoginAt, + updatedAt: entity.updatedAt, + }); + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep new file mode 100644 index 00000000..b87434af --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep @@ -0,0 +1 @@ +# Prisma migrations will be stored here diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/prisma.service.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/prisma.service.ts new file mode 100644 index 00000000..bb6565f3 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/schema.prisma b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/schema.prisma new file mode 100644 index 00000000..e15bef5e --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/prisma/schema.prisma @@ -0,0 +1,133 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model UserAccount { + userId BigInt @id @default(autoincrement()) @map("user_id") + accountSequence BigInt @unique @map("account_sequence") + + phoneNumber String? @unique @map("phone_number") @db.VarChar(20) + nickname String @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.VarChar(500) + + inviterSequence BigInt? @map("inviter_sequence") + referralCode String @unique @map("referral_code") @db.VarChar(10) + + provinceCode String @map("province_code") @db.VarChar(10) + cityCode String @map("city_code") @db.VarChar(10) + address String? @db.VarChar(500) + + kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) + realName String? @map("real_name") @db.VarChar(100) + idCardNumber String? @map("id_card_number") @db.VarChar(20) + idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500) + idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500) + kycVerifiedAt DateTime? @map("kyc_verified_at") + + status String @default("ACTIVE") @db.VarChar(20) + + registeredAt DateTime @default(now()) @map("registered_at") + lastLoginAt DateTime? @map("last_login_at") + updatedAt DateTime @updatedAt @map("updated_at") + + devices UserDevice[] + walletAddresses WalletAddress[] + + @@index([phoneNumber], name: "idx_phone") + @@index([accountSequence], name: "idx_sequence") + @@index([referralCode], name: "idx_referral_code") + @@index([inviterSequence], name: "idx_inviter") + @@index([provinceCode, cityCode], name: "idx_province_city") + @@index([kycStatus], name: "idx_kyc_status") + @@index([status], name: "idx_status") + @@map("user_accounts") +} + +model UserDevice { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + deviceName String? @map("device_name") @db.VarChar(100) + + addedAt DateTime @default(now()) @map("added_at") + lastActiveAt DateTime @default(now()) @map("last_active_at") + + user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([userId, deviceId], name: "uk_user_device") + @@index([deviceId], name: "idx_device") + @@index([userId], name: "idx_user") + @@index([lastActiveAt], name: "idx_last_active") + @@map("user_devices") +} + +model WalletAddress { + addressId BigInt @id @default(autoincrement()) @map("address_id") + userId BigInt @map("user_id") + + chainType String @map("chain_type") @db.VarChar(20) + address String @db.VarChar(100) + + encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text + + status String @default("ACTIVE") @db.VarChar(20) + + boundAt DateTime @default(now()) @map("bound_at") + + user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([userId, chainType], name: "uk_user_chain") + @@unique([chainType, address], name: "uk_chain_address") + @@index([userId], name: "idx_wallet_user") + @@index([address], name: "idx_address") + @@map("wallet_addresses") +} + +model AccountSequenceGenerator { + id Int @id @default(1) + currentSequence BigInt @default(0) @map("current_sequence") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("account_sequence_generator") +} + +model UserEvent { + eventId BigInt @id @default(autoincrement()) @map("event_id") + eventType String @map("event_type") @db.VarChar(50) + + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + + eventData Json @map("event_data") + + userId BigInt? @map("user_id") + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6) + version Int @default(1) + + @@index([aggregateType, aggregateId], name: "idx_aggregate") + @@index([eventType], name: "idx_event_type") + @@index([userId], name: "idx_event_user") + @@index([occurredAt], name: "idx_occurred") + @@map("user_events") +} + +model DeviceToken { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64) + + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + @@index([userId, deviceId], name: "idx_user_device_token") + @@index([expiresAt], name: "idx_expires") + @@map("device_tokens") +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts b/backend/services/identity-service/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts new file mode 100644 index 00000000..f02341ca --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts @@ -0,0 +1,233 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { UserAccountRepository, Pagination } from '@/domain/repositories/user-account.repository.interface'; +import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; +import { + UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, + AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus, +} from '@/domain/value-objects'; + +@Injectable() +export class UserAccountRepositoryImpl implements UserAccountRepository { + constructor(private readonly prisma: PrismaService) {} + + async save(account: UserAccount): Promise { + const devices = account.getAllDevices(); + const wallets = account.getAllWalletAddresses(); + + await this.prisma.$transaction(async (tx) => { + await tx.userAccount.upsert({ + where: { userId: BigInt(account.userId.value) }, + create: { + userId: BigInt(account.userId.value), + accountSequence: BigInt(account.accountSequence.value), + phoneNumber: account.phoneNumber?.value || null, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + inviterSequence: account.inviterSequence ? BigInt(account.inviterSequence.value) : null, + referralCode: account.referralCode.value, + provinceCode: account.province.value, + cityCode: account.city.value, + address: account.addressDetail, + kycStatus: account.kycStatus, + realName: account.kycInfo?.realName || null, + idCardNumber: account.kycInfo?.idCardNumber || null, + idCardFrontUrl: account.kycInfo?.idCardFrontUrl || null, + idCardBackUrl: account.kycInfo?.idCardBackUrl || null, + status: account.status, + registeredAt: account.registeredAt, + lastLoginAt: account.lastLoginAt, + }, + update: { + phoneNumber: account.phoneNumber?.value || null, + nickname: account.nickname, + avatarUrl: account.avatarUrl, + provinceCode: account.province.value, + cityCode: account.city.value, + address: account.addressDetail, + kycStatus: account.kycStatus, + realName: account.kycInfo?.realName || null, + idCardNumber: account.kycInfo?.idCardNumber || null, + idCardFrontUrl: account.kycInfo?.idCardFrontUrl || null, + idCardBackUrl: account.kycInfo?.idCardBackUrl || null, + status: account.status, + lastLoginAt: account.lastLoginAt, + }, + }); + + // Sync devices + await tx.userDevice.deleteMany({ where: { userId: BigInt(account.userId.value) } }); + if (devices.length > 0) { + await tx.userDevice.createMany({ + data: devices.map((d) => ({ + userId: BigInt(account.userId.value), + deviceId: d.deviceId, + deviceName: d.deviceName, + addedAt: d.addedAt, + lastActiveAt: d.lastActiveAt, + })), + }); + } + }); + } + + async saveWallets(userId: UserId, wallets: WalletAddress[]): Promise { + await this.prisma.walletAddress.createMany({ + data: wallets.map((w) => ({ + userId: BigInt(userId.value), + chainType: w.chainType, + address: w.address, + encryptedMnemonic: w.encryptedMnemonic, + status: w.status, + boundAt: w.boundAt, + })), + skipDuplicates: true, + }); + } + + async findById(userId: UserId): Promise { + const data = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId.value) }, + include: { devices: true, walletAddresses: true }, + }); + return data ? this.toDomain(data) : null; + } + + async findByAccountSequence(sequence: AccountSequence): Promise { + const data = await this.prisma.userAccount.findUnique({ + where: { accountSequence: BigInt(sequence.value) }, + include: { devices: true, walletAddresses: true }, + }); + return data ? this.toDomain(data) : null; + } + + async findByDeviceId(deviceId: string): Promise { + const device = await this.prisma.userDevice.findFirst({ where: { deviceId } }); + if (!device) return null; + return this.findById(UserId.create(device.userId.toString())); + } + + async findByPhoneNumber(phoneNumber: PhoneNumber): Promise { + const data = await this.prisma.userAccount.findUnique({ + where: { phoneNumber: phoneNumber.value }, + include: { devices: true, walletAddresses: true }, + }); + return data ? this.toDomain(data) : null; + } + + async findByReferralCode(referralCode: ReferralCode): Promise { + const data = await this.prisma.userAccount.findUnique({ + where: { referralCode: referralCode.value }, + include: { devices: true, walletAddresses: true }, + }); + return data ? this.toDomain(data) : null; + } + + async findByWalletAddress(chainType: ChainType, address: string): Promise { + const wallet = await this.prisma.walletAddress.findUnique({ + where: { uk_chain_address: { chainType, address } }, + }); + if (!wallet) return null; + return this.findById(UserId.create(wallet.userId.toString())); + } + + async getMaxAccountSequence(): Promise { + const result = await this.prisma.userAccount.aggregate({ _max: { accountSequence: true } }); + return result._max.accountSequence ? AccountSequence.create(Number(result._max.accountSequence)) : null; + } + + async getNextAccountSequence(): Promise { + const result = await this.prisma.$transaction(async (tx) => { + const updated = await tx.accountSequenceGenerator.update({ + where: { id: 1 }, + data: { currentSequence: { increment: 1 } }, + }); + return updated.currentSequence; + }); + return AccountSequence.create(Number(result)); + } + + async findUsers( + filters?: { status?: AccountStatus; kycStatus?: KYCStatus; province?: string; city?: string; keyword?: string }, + pagination?: Pagination, + ): Promise { + const where: any = {}; + if (filters?.status) where.status = filters.status; + if (filters?.kycStatus) where.kycStatus = filters.kycStatus; + if (filters?.province) where.provinceCode = filters.province; + if (filters?.city) where.cityCode = filters.city; + if (filters?.keyword) { + where.OR = [ + { nickname: { contains: filters.keyword } }, + { phoneNumber: { contains: filters.keyword } }, + ]; + } + + const data = await this.prisma.userAccount.findMany({ + where, + include: { devices: true, walletAddresses: true }, + skip: pagination ? (pagination.page - 1) * pagination.limit : undefined, + take: pagination?.limit, + orderBy: { registeredAt: 'desc' }, + }); + + return data.map((d) => this.toDomain(d)); + } + + async countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise { + const where: any = {}; + if (filters?.status) where.status = filters.status; + if (filters?.kycStatus) where.kycStatus = filters.kycStatus; + return this.prisma.userAccount.count({ where }); + } + + private toDomain(data: any): UserAccount { + const devices = data.devices.map( + (d: any) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt), + ); + + const wallets = data.walletAddresses.map((w: any) => + WalletAddress.reconstruct({ + addressId: w.addressId.toString(), + userId: w.userId.toString(), + chainType: w.chainType as ChainType, + address: w.address, + encryptedMnemonic: w.encryptedMnemonic || '', + status: w.status as AddressStatus, + boundAt: w.boundAt, + }), + ); + + const kycInfo = + data.realName && data.idCardNumber + ? KYCInfo.create({ + realName: data.realName, + idCardNumber: data.idCardNumber, + idCardFrontUrl: data.idCardFrontUrl || '', + idCardBackUrl: data.idCardBackUrl || '', + }) + : null; + + return UserAccount.reconstruct({ + userId: data.userId.toString(), + accountSequence: Number(data.accountSequence), + devices, + phoneNumber: data.phoneNumber, + nickname: data.nickname, + avatarUrl: data.avatarUrl, + inviterSequence: data.inviterSequence ? Number(data.inviterSequence) : null, + referralCode: data.referralCode, + province: data.provinceCode, + city: data.cityCode, + address: data.address, + walletAddresses: wallets, + kycInfo, + kycStatus: data.kycStatus as KYCStatus, + status: data.status as AccountStatus, + registeredAt: data.registeredAt, + lastLoginAt: data.lastLoginAt, + updatedAt: data.updatedAt, + }); + } +} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.module.ts b/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.module.ts new file mode 100644 index 00000000..b4958682 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.service.ts b/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.service.ts new file mode 100644 index 00000000..ddc6e048 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/infrastructure/redis/redis.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly client: Redis; + + constructor(private readonly configService: ConfigService) { + this.client = new Redis({ + host: this.configService.get('REDIS_HOST', 'localhost'), + port: this.configService.get('REDIS_PORT', 6379), + password: this.configService.get('REDIS_PASSWORD') || undefined, + db: this.configService.get('REDIS_DB', 0), + }); + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds); + } else { + await this.client.set(key, value); + } + } + + 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 incr(key: string): Promise { + return this.client.incr(key); + } + + async expire(key: string, seconds: number): Promise { + await this.client.expire(key, seconds); + } + + onModuleDestroy() { + this.client.disconnect(); + } +} diff --git a/backend/services/identity-service/identity-service/src/main.ts b/backend/services/identity-service/identity-service/src/main.ts new file mode 100644 index 00000000..61e85873 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/main.ts @@ -0,0 +1,45 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + // Validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + // CORS + app.enableCors({ + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, + }); + + // Swagger + const config = new DocumentBuilder() + .setTitle('Identity Service API') + .setDescription('RWA用户身份服务API') + .setVersion('2.0.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.APP_PORT || 3000; + await app.listen(port); + console.log(`Identity Service is running on port ${port}`); + console.log(`Swagger docs: http://localhost:${port}/api/docs`); +} + +bootstrap(); diff --git a/backend/services/identity-service/identity-service/src/shared/decorators/current-user.decorator.ts b/backend/services/identity-service/identity-service/src/shared/decorators/current-user.decorator.ts new file mode 100644 index 00000000..7c59a823 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/decorators/current-user.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { CurrentUserData } from '@/shared/guards/jwt-auth.guard'; + +export const CurrentUser = createParamDecorator( + (data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as CurrentUserData; + return data ? user?.[data] : user; + }, +); diff --git a/backend/services/identity-service/identity-service/src/shared/decorators/public.decorator.ts b/backend/services/identity-service/identity-service/src/shared/decorators/public.decorator.ts new file mode 100644 index 00000000..b3845e12 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/services/identity-service/identity-service/src/shared/exceptions/application.exception.ts b/backend/services/identity-service/identity-service/src/shared/exceptions/application.exception.ts new file mode 100644 index 00000000..b778a858 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/exceptions/application.exception.ts @@ -0,0 +1,11 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class ApplicationException extends HttpException { + constructor( + message: string, + public readonly code?: string, + status: HttpStatus = HttpStatus.BAD_REQUEST, + ) { + super({ message, code, success: false }, status); + } +} diff --git a/backend/services/identity-service/identity-service/src/shared/exceptions/domain.exception.ts b/backend/services/identity-service/identity-service/src/shared/exceptions/domain.exception.ts new file mode 100644 index 00000000..ff7e0190 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/exceptions/domain.exception.ts @@ -0,0 +1,40 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class DomainError extends Error { + constructor(message: string) { + super(message); + this.name = 'DomainError'; + } +} + +export class ApplicationError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'ApplicationError'; + } +} + +export class BusinessException extends HttpException { + constructor( + message: string, + public readonly code?: string, + status: HttpStatus = HttpStatus.BAD_REQUEST, + ) { + super({ message, code, success: false }, status); + } +} + +export class UnauthorizedException extends HttpException { + constructor(message: string = '未授权访问') { + super({ message, success: false }, HttpStatus.UNAUTHORIZED); + } +} + +export class NotFoundException extends HttpException { + constructor(message: string = '资源不存在') { + super({ message, success: false }, HttpStatus.NOT_FOUND); + } +} diff --git a/backend/services/identity-service/identity-service/src/shared/filters/domain-exception.filter.ts b/backend/services/identity-service/identity-service/src/shared/filters/domain-exception.filter.ts new file mode 100644 index 00000000..cd9c9fa8 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/filters/domain-exception.filter.ts @@ -0,0 +1,17 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; +import { DomainError } from '@/shared/exceptions/domain.exception'; + +@Catch(DomainError) +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: DomainError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.BAD_REQUEST).json({ + success: false, + message: exception.message, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/backend/services/identity-service/identity-service/src/shared/filters/global-exception.filter.ts b/backend/services/identity-service/identity-service/src/shared/filters/global-exception.filter.ts new file mode 100644 index 00000000..9972c226 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/filters/global-exception.filter.ts @@ -0,0 +1,65 @@ +import { + ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, + Injectable, NestInterceptor, ExecutionContext, CallHandler, +} from '@nestjs/common'; +import { Response } from 'express'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { DomainError, ApplicationError } from '@/shared/exceptions/domain.exception'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = '服务器内部错误'; + let code: string | undefined; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + message = (exceptionResponse as any).message || message; + code = (exceptionResponse as any).code; + } else { + message = exceptionResponse as string; + } + } else if (exception instanceof DomainError) { + status = HttpStatus.BAD_REQUEST; + message = exception.message; + } else if (exception instanceof ApplicationError) { + status = HttpStatus.BAD_REQUEST; + message = exception.message; + code = exception.code; + } else if (exception instanceof Error) { + message = exception.message; + } + + response.status(status).json({ + success: false, + code, + message, + timestamp: new Date().toISOString(), + }); + } +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((data) => ({ + success: true, + data, + })), + ); + } +} diff --git a/backend/services/identity-service/identity-service/src/shared/guards/jwt-auth.guard.ts b/backend/services/identity-service/identity-service/src/shared/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..1dfafec9 --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/guards/jwt-auth.guard.ts @@ -0,0 +1,68 @@ +import { Injectable, CanActivate, ExecutionContext, createParamDecorator, SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { UnauthorizedException } from '@/shared/exceptions/domain.exception'; + +export interface JwtPayload { + userId: string; + accountSequence: number; + deviceId: string; + type: 'access' | 'refresh'; +} + +export interface CurrentUserData { + userId: string; + accountSequence: number; + deviceId: string; +} + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +export const CurrentUser = createParamDecorator( + (data: keyof CurrentUserData | undefined, ctx: ExecutionContext): CurrentUserData | string | number => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as CurrentUserData; + return data ? user?.[data] : user; + }, +); + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) throw new UnauthorizedException('缺少认证令牌'); + + try { + const payload = await this.jwtService.verifyAsync(token); + if (payload.type !== 'access') throw new UnauthorizedException('无效的令牌类型'); + request.user = { + userId: payload.userId, + accountSequence: payload.accountSequence, + deviceId: payload.deviceId, + }; + } catch { + throw new UnauthorizedException('令牌无效或已过期'); + } + + return true; + } + + private extractTokenFromHeader(request: any): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/backend/services/identity-service/identity-service/src/shared/interceptors/transform.interceptor.ts b/backend/services/identity-service/identity-service/src/shared/interceptors/transform.interceptor.ts new file mode 100644 index 00000000..8501f5aa --- /dev/null +++ b/backend/services/identity-service/identity-service/src/shared/interceptors/transform.interceptor.ts @@ -0,0 +1,21 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((data) => ({ + success: true, + data, + })), + ); + } +} diff --git a/backend/services/identity-service/identity-service/tsconfig.json b/backend/services/identity-service/identity-service/tsconfig.json new file mode 100644 index 00000000..bd3c3946 --- /dev/null +++ b/backend/services/identity-service/identity-service/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/backend/services/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep b/backend/services/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep new file mode 100644 index 00000000..b87434af --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/prisma/migrations/.gitkeep @@ -0,0 +1 @@ +# Prisma migrations will be stored here diff --git a/backend/services/identity-service/src/infrastructure/persistence/prisma/schema.prisma b/backend/services/identity-service/src/infrastructure/persistence/prisma/schema.prisma new file mode 100644 index 00000000..e15bef5e --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/prisma/schema.prisma @@ -0,0 +1,133 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model UserAccount { + userId BigInt @id @default(autoincrement()) @map("user_id") + accountSequence BigInt @unique @map("account_sequence") + + phoneNumber String? @unique @map("phone_number") @db.VarChar(20) + nickname String @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.VarChar(500) + + inviterSequence BigInt? @map("inviter_sequence") + referralCode String @unique @map("referral_code") @db.VarChar(10) + + provinceCode String @map("province_code") @db.VarChar(10) + cityCode String @map("city_code") @db.VarChar(10) + address String? @db.VarChar(500) + + kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) + realName String? @map("real_name") @db.VarChar(100) + idCardNumber String? @map("id_card_number") @db.VarChar(20) + idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500) + idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500) + kycVerifiedAt DateTime? @map("kyc_verified_at") + + status String @default("ACTIVE") @db.VarChar(20) + + registeredAt DateTime @default(now()) @map("registered_at") + lastLoginAt DateTime? @map("last_login_at") + updatedAt DateTime @updatedAt @map("updated_at") + + devices UserDevice[] + walletAddresses WalletAddress[] + + @@index([phoneNumber], name: "idx_phone") + @@index([accountSequence], name: "idx_sequence") + @@index([referralCode], name: "idx_referral_code") + @@index([inviterSequence], name: "idx_inviter") + @@index([provinceCode, cityCode], name: "idx_province_city") + @@index([kycStatus], name: "idx_kyc_status") + @@index([status], name: "idx_status") + @@map("user_accounts") +} + +model UserDevice { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + deviceName String? @map("device_name") @db.VarChar(100) + + addedAt DateTime @default(now()) @map("added_at") + lastActiveAt DateTime @default(now()) @map("last_active_at") + + user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([userId, deviceId], name: "uk_user_device") + @@index([deviceId], name: "idx_device") + @@index([userId], name: "idx_user") + @@index([lastActiveAt], name: "idx_last_active") + @@map("user_devices") +} + +model WalletAddress { + addressId BigInt @id @default(autoincrement()) @map("address_id") + userId BigInt @map("user_id") + + chainType String @map("chain_type") @db.VarChar(20) + address String @db.VarChar(100) + + encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text + + status String @default("ACTIVE") @db.VarChar(20) + + boundAt DateTime @default(now()) @map("bound_at") + + user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([userId, chainType], name: "uk_user_chain") + @@unique([chainType, address], name: "uk_chain_address") + @@index([userId], name: "idx_wallet_user") + @@index([address], name: "idx_address") + @@map("wallet_addresses") +} + +model AccountSequenceGenerator { + id Int @id @default(1) + currentSequence BigInt @default(0) @map("current_sequence") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("account_sequence_generator") +} + +model UserEvent { + eventId BigInt @id @default(autoincrement()) @map("event_id") + eventType String @map("event_type") @db.VarChar(50) + + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + + eventData Json @map("event_data") + + userId BigInt? @map("user_id") + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6) + version Int @default(1) + + @@index([aggregateType, aggregateId], name: "idx_aggregate") + @@index([eventType], name: "idx_event_type") + @@index([userId], name: "idx_event_user") + @@index([occurredAt], name: "idx_occurred") + @@map("user_events") +} + +model DeviceToken { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + deviceId String @map("device_id") @db.VarChar(100) + + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64) + + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + @@index([userId, deviceId], name: "idx_user_device_token") + @@index([expiresAt], name: "idx_expires") + @@map("device_tokens") +}