diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..2361f390 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)", + "Bash(tree:*)", + "Bash(find:*)", + "Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")" + ], + "deny": [], + "ask": [] + } +} diff --git a/backend/services/backup-service/IMPLEMENTATION_GUIDE.md b/backend/services/backup-service/IMPLEMENTATION_GUIDE.md new file mode 100644 index 00000000..1117bc14 --- /dev/null +++ b/backend/services/backup-service/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,901 @@ +# Backup Service 实施指导 + +## 1. 概述 + +### 1.1 服务定位 + +`backup-service` 是 RWA Durian 平台的 MPC 备份分片存储服务,负责安全存储用户的 Backup Share (Party 2)。 + +**核心职责:** +- 安全存储 MPC 2-of-3 的第三个分片 (Backup Share) +- 提供分片的存取 API +- 支持用户账户恢复时的分片验证和获取 +- 审计日志记录 + +### 1.2 安全要求 + +**关键安全原则:必须部署在与 identity-service 不同的物理服务器上!** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ MPC 分片物理隔离架构 │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ 物理服务器 A 物理服务器 B 用户设备 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │ +│ │identity-service │ │ backup-service │ │Flutter App │ │ +│ │Server Share (0) │ │Backup Share (2) │ │Client (1) │ │ +│ └─────────────────┘ └─────────────────┘ └────────────┘ │ +│ │ +│ 任意单点被攻破,攻击者只能获得 1 个分片,无法重建私钥 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +如果 Server Share 和 Backup Share 在同一台物理服务器,攻击者攻破该服务器后可获得 2 个分片,直接重建私钥,**MPC 安全性完全失效**! + +### 1.3 技术栈 + +与 identity-service 保持一致: +- **框架**: NestJS 10.x +- **语言**: TypeScript 5.x +- **数据库**: PostgreSQL 15+ (独立数据库实例) +- **ORM**: Prisma 5.x +- **缓存**: Redis (可选) +- **消息队列**: Kafka (可选,用于接收 identity-service 的事件) + +--- + +## 2. 目录结构 + +参考 identity-service 的 DDD + Hexagonal 架构: + +``` +backup-service/ +├── prisma/ +│ ├── schema.prisma # 数据库模型 +│ └── migrations/ # 数据库迁移 +├── src/ +│ ├── api/ # 接口层 (Adapters - Driving) +│ │ ├── controllers/ +│ │ │ └── backup-share.controller.ts +│ │ ├── dto/ +│ │ │ ├── request/ +│ │ │ │ ├── store-share.dto.ts +│ │ │ │ └── retrieve-share.dto.ts +│ │ │ └── response/ +│ │ │ └── share-info.dto.ts +│ │ └── api.module.ts +│ │ +│ ├── application/ # 应用层 (Use Cases) +│ │ ├── commands/ +│ │ │ ├── store-backup-share/ +│ │ │ │ ├── store-backup-share.command.ts +│ │ │ │ └── store-backup-share.handler.ts +│ │ │ └── revoke-share/ +│ │ │ ├── revoke-share.command.ts +│ │ │ └── revoke-share.handler.ts +│ │ ├── queries/ +│ │ │ └── get-backup-share/ +│ │ │ ├── get-backup-share.query.ts +│ │ │ └── get-backup-share.handler.ts +│ │ ├── services/ +│ │ │ └── backup-share-application.service.ts +│ │ └── application.module.ts +│ │ +│ ├── domain/ # 领域层 (Core Business Logic) +│ │ ├── entities/ +│ │ │ └── backup-share.entity.ts +│ │ ├── repositories/ +│ │ │ └── backup-share.repository.interface.ts +│ │ ├── services/ +│ │ │ └── share-encryption.domain-service.ts +│ │ ├── value-objects/ +│ │ │ ├── share-id.vo.ts +│ │ │ └── encrypted-data.vo.ts +│ │ └── domain.module.ts +│ │ +│ ├── infrastructure/ # 基础设施层 (Adapters - Driven) +│ │ ├── persistence/ +│ │ │ ├── prisma/ +│ │ │ │ └── prisma.service.ts +│ │ │ └── repositories/ +│ │ │ └── backup-share.repository.impl.ts +│ │ ├── crypto/ +│ │ │ └── aes-encryption.service.ts +│ │ └── infrastructure.module.ts +│ │ +│ ├── shared/ # 共享模块 +│ │ ├── guards/ +│ │ │ └── service-auth.guard.ts # 服务间认证 +│ │ ├── filters/ +│ │ │ └── global-exception.filter.ts +│ │ └── interceptors/ +│ │ └── audit-log.interceptor.ts +│ │ +│ ├── config/ +│ │ └── index.ts +│ ├── app.module.ts +│ └── main.ts +│ +├── test/ +│ ├── unit/ +│ └── e2e/ +│ └── backup-share.e2e-spec.ts +│ +├── .env.example +├── .env.development +├── package.json +├── tsconfig.json +├── nest-cli.json +└── Dockerfile +``` + +--- + +## 3. 数据库设计 + +### 3.1 Prisma Schema + +```prisma +// prisma/schema.prisma + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// 备份分片存储 +model BackupShare { + shareId BigInt @id @default(autoincrement()) @map("share_id") + + // 用户标识 (来自 identity-service) + userId BigInt @unique @map("user_id") + accountSequence BigInt @unique @map("account_sequence") + + // MPC 密钥信息 + publicKey String @unique @map("public_key") @db.VarChar(130) + partyIndex Int @default(2) @map("party_index") // Backup = Party 2 + threshold Int @default(2) + totalParties Int @default(3) @map("total_parties") + + // 加密的分片数据 (AES-256-GCM 加密) + encryptedShareData String @map("encrypted_share_data") @db.Text + encryptionKeyId String @map("encryption_key_id") @db.VarChar(64) // 密钥轮换支持 + + // 状态管理 + status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, ROTATED + + // 访问控制 + accessCount Int @default(0) @map("access_count") // 访问次数限制 + lastAccessedAt DateTime? @map("last_accessed_at") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + revokedAt DateTime? @map("revoked_at") + + // 索引 + @@index([publicKey], name: "idx_backup_public_key") + @@index([status], name: "idx_backup_status") + @@index([createdAt], name: "idx_backup_created") + @@map("backup_shares") +} + +// 访问审计日志 +model ShareAccessLog { + logId BigInt @id @default(autoincrement()) @map("log_id") + + shareId BigInt @map("share_id") + userId BigInt @map("user_id") + + action String @db.VarChar(20) // STORE, RETRIEVE, REVOKE, ROTATE + sourceService String @map("source_service") @db.VarChar(50) // identity-service, recovery-service + sourceIp String @map("source_ip") @db.VarChar(45) + + success Boolean @default(true) + errorMessage String? @map("error_message") @db.Text + + createdAt DateTime @default(now()) @map("created_at") + + @@index([shareId], name: "idx_log_share") + @@index([userId], name: "idx_log_user") + @@index([action], name: "idx_log_action") + @@index([createdAt], name: "idx_log_created") + @@map("share_access_logs") +} +``` + +--- + +## 4. API 设计 + +### 4.1 存储备份分片 + +**POST /backup-share/store** + +由 identity-service 在创建账户时调用。 + +```typescript +// Request DTO +class StoreBackupShareDto { + @IsNotEmpty() + userId: string; // identity-service 的用户ID + + @IsNotEmpty() + accountSequence: number; // 账户序列号 + + @IsNotEmpty() + publicKey: string; // MPC 公钥 + + @IsNotEmpty() + encryptedShareData: string; // 已加密的分片数据 + + @IsOptional() + threshold?: number; // 默认 2 + + @IsOptional() + totalParties?: number; // 默认 3 +} + +// Response +{ + "success": true, + "shareId": "123", + "message": "Backup share stored successfully" +} +``` + +### 4.2 获取备份分片 + +**POST /backup-share/retrieve** + +用于账户恢复场景,需要额外的身份验证。 + +```typescript +// Request DTO +class RetrieveBackupShareDto { + @IsNotEmpty() + userId: string; + + @IsNotEmpty() + publicKey: string; + + @IsNotEmpty() + recoveryToken: string; // 恢复令牌 (由 identity-service 签发) + + @IsOptional() + deviceId?: string; // 新设备ID +} + +// Response +{ + "success": true, + "encryptedShareData": "...", + "partyIndex": 2, + "publicKey": "..." +} +``` + +### 4.3 撤销分片 + +**POST /backup-share/revoke** + +用于密钥轮换或账户注销。 + +```typescript +// Request DTO +class RevokeShareDto { + @IsNotEmpty() + userId: string; + + @IsNotEmpty() + publicKey: string; + + @IsNotEmpty() + reason: string; // ROTATION, ACCOUNT_CLOSED, SECURITY_BREACH +} +``` + +--- + +## 5. 服务间认证 + +### 5.1 认证机制 + +backup-service 不对外公开,只接受来自内部服务的请求。使用 **服务间 JWT** 或 **mTLS** 进行认证。 + +```typescript +// src/shared/guards/service-auth.guard.ts + +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class ServiceAuthGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const serviceToken = request.headers['x-service-token']; + + if (!serviceToken) { + throw new UnauthorizedException('Missing service token'); + } + + try { + const secret = this.configService.get('SERVICE_JWT_SECRET'); + const payload = jwt.verify(serviceToken, secret) as { service: string }; + + // 只允许特定服务访问 + const allowedServices = ['identity-service', 'recovery-service']; + if (!allowedServices.includes(payload.service)) { + throw new UnauthorizedException('Service not authorized'); + } + + request.sourceService = payload.service; + return true; + } catch (error) { + throw new UnauthorizedException('Invalid service token'); + } + } +} +``` + +### 5.2 identity-service 调用示例 + +在 identity-service 中添加调用 backup-service 的客户端: + +```typescript +// identity-service/src/infrastructure/external/backup/backup-client.service.ts + +@Injectable() +export class BackupClientService { + private readonly backupServiceUrl: string; + private readonly serviceToken: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.backupServiceUrl = this.configService.get('BACKUP_SERVICE_URL'); + this.serviceToken = this.generateServiceToken(); + } + + async storeBackupShare(params: { + userId: string; + accountSequence: number; + publicKey: string; + encryptedShareData: string; + }): Promise { + await firstValueFrom( + this.httpService.post( + `${this.backupServiceUrl}/backup-share/store`, + params, + { + headers: { + 'Content-Type': 'application/json', + 'X-Service-Token': this.serviceToken, + }, + }, + ), + ); + } + + private generateServiceToken(): string { + const secret = this.configService.get('SERVICE_JWT_SECRET'); + return jwt.sign( + { service: 'identity-service', iat: Math.floor(Date.now() / 1000) }, + secret, + { expiresIn: '1h' }, + ); + } +} +``` + +--- + +## 6. 核心实现 + +### 6.1 领域实体 + +```typescript +// src/domain/entities/backup-share.entity.ts + +export class BackupShare { + private constructor( + private readonly _shareId: bigint | null, + private readonly _userId: bigint, + private readonly _accountSequence: bigint, + private readonly _publicKey: string, + private readonly _partyIndex: number, + private readonly _threshold: number, + private readonly _totalParties: number, + private _encryptedShareData: string, + private _encryptionKeyId: string, + private _status: BackupShareStatus, + private _accessCount: number, + private readonly _createdAt: Date, + ) {} + + static create(params: { + userId: bigint; + accountSequence: bigint; + publicKey: string; + encryptedShareData: string; + encryptionKeyId: string; + }): BackupShare { + return new BackupShare( + null, + params.userId, + params.accountSequence, + params.publicKey, + 2, // Backup = Party 2 + 2, // threshold + 3, // totalParties + params.encryptedShareData, + params.encryptionKeyId, + BackupShareStatus.ACTIVE, + 0, + new Date(), + ); + } + + recordAccess(): void { + if (this._status !== BackupShareStatus.ACTIVE) { + throw new DomainError('Cannot access revoked share'); + } + this._accessCount++; + } + + revoke(reason: string): void { + if (this._status === BackupShareStatus.REVOKED) { + throw new DomainError('Share already revoked'); + } + this._status = BackupShareStatus.REVOKED; + } + + rotate(newEncryptedData: string, newKeyId: string): void { + this._encryptedShareData = newEncryptedData; + this._encryptionKeyId = newKeyId; + this._status = BackupShareStatus.ACTIVE; + } + + // Getters... + get shareId(): bigint | null { return this._shareId; } + get userId(): bigint { return this._userId; } + get publicKey(): string { return this._publicKey; } + get encryptedShareData(): string { return this._encryptedShareData; } + get status(): BackupShareStatus { return this._status; } + get accessCount(): number { return this._accessCount; } +} + +export enum BackupShareStatus { + ACTIVE = 'ACTIVE', + REVOKED = 'REVOKED', + ROTATED = 'ROTATED', +} +``` + +### 6.2 应用服务 + +```typescript +// src/application/services/backup-share-application.service.ts + +@Injectable() +export class BackupShareApplicationService { + private readonly logger = new Logger(BackupShareApplicationService.name); + + constructor( + @Inject(BACKUP_SHARE_REPOSITORY) + private readonly repository: BackupShareRepository, + private readonly encryptionService: AesEncryptionService, + private readonly auditService: AuditLogService, + ) {} + + async storeBackupShare(command: StoreBackupShareCommand): Promise { + this.logger.log(`Storing backup share for user: ${command.userId}`); + + // 检查是否已存在 + const existing = await this.repository.findByUserId(BigInt(command.userId)); + if (existing) { + throw new ApplicationError('Backup share already exists for this user'); + } + + // 二次加密 (identity-service 已加密一次,这里再加密一次) + const { encrypted, keyId } = await this.encryptionService.encrypt( + command.encryptedShareData, + ); + + // 创建实体 + const share = BackupShare.create({ + userId: BigInt(command.userId), + accountSequence: BigInt(command.accountSequence), + publicKey: command.publicKey, + encryptedShareData: encrypted, + encryptionKeyId: keyId, + }); + + // 保存 + const saved = await this.repository.save(share); + + // 记录审计日志 + await this.auditService.log({ + shareId: saved.shareId!, + userId: BigInt(command.userId), + action: 'STORE', + sourceService: command.sourceService, + sourceIp: command.sourceIp, + success: true, + }); + + this.logger.log(`Backup share stored: shareId=${saved.shareId}`); + return saved.shareId!.toString(); + } + + async retrieveBackupShare(query: GetBackupShareQuery): Promise { + this.logger.log(`Retrieving backup share for user: ${query.userId}`); + + const share = await this.repository.findByUserIdAndPublicKey( + BigInt(query.userId), + query.publicKey, + ); + + if (!share) { + throw new ApplicationError('Backup share not found'); + } + + if (share.status !== BackupShareStatus.ACTIVE) { + throw new ApplicationError('Backup share is not active'); + } + + // 记录访问 + share.recordAccess(); + await this.repository.save(share); + + // 解密 + const decrypted = await this.encryptionService.decrypt( + share.encryptedShareData, + share.encryptionKeyId, + ); + + // 记录审计日志 + await this.auditService.log({ + shareId: share.shareId!, + userId: BigInt(query.userId), + action: 'RETRIEVE', + sourceService: query.sourceService, + sourceIp: query.sourceIp, + success: true, + }); + + return { + encryptedShareData: decrypted, + partyIndex: share.partyIndex, + publicKey: share.publicKey, + }; + } +} +``` + +--- + +## 7. 环境配置 + +### 7.1 .env.example + +```bash +# Database (必须是独立的数据库实例,不能与 identity-service 共享!) +DATABASE_URL="postgresql://postgres:password@backup-db-server:5432/rwa_backup?schema=public" + +# Server +APP_PORT=3002 +APP_ENV="development" + +# Service Authentication +SERVICE_JWT_SECRET="your-super-secret-service-jwt-key" +ALLOWED_SERVICES="identity-service,recovery-service" + +# Encryption (用于二次加密备份分片) +BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex" +BACKUP_ENCRYPTION_KEY_ID="key-v1" + +# Rate Limiting +MAX_RETRIEVE_PER_DAY=3 # 每用户每天最多获取 3 次 +MAX_STORE_PER_MINUTE=10 # 每分钟最多存储 10 个 + +# Audit +AUDIT_LOG_RETENTION_DAYS=365 # 审计日志保留 365 天 + +# Monitoring +PROMETHEUS_ENABLED=true +PROMETHEUS_PORT=9102 +``` + +--- + +## 8. 部署要求 + +### 8.1 物理隔离 + +**强制要求:** + +```yaml +# docker-compose.yml (仅供参考架构,生产环境必须分开部署) + +# ❌ 错误示例 - 同一台机器 +services: + identity-service: + ... + backup-service: # 不能在这里! + ... + +# ✅ 正确示例 - 分开部署 +# 机器 A: docker-compose-main.yml +services: + identity-service: + ... + +# 机器 B: docker-compose-backup.yml (异地机房) +services: + backup-service: + ... +``` + +### 8.2 网络安全 + +```yaml +# backup-service 的网络策略 +- 只允许来自 identity-service 的入站连接 +- 禁止任何公网访问 +- 使用 VPN 或专线连接主机房和备份机房 +``` + +### 8.3 数据库安全 + +``` +- 使用独立的 PostgreSQL 实例 +- 启用 TLS 加密连接 +- 定期备份到第三方存储 +- 启用审计日志 +``` + +--- + +## 9. 测试 + +### 9.1 E2E 测试 + +```typescript +// test/e2e/backup-share.e2e-spec.ts + +describe('BackupShare (e2e)', () => { + let app: INestApplication; + let serviceToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + // 生成测试用的服务令牌 + serviceToken = jwt.sign( + { service: 'identity-service' }, + process.env.SERVICE_JWT_SECRET, + ); + }); + + describe('POST /backup-share/store', () => { + it('should store backup share successfully', async () => { + const response = await request(app.getHttpServer()) + .post('/backup-share/store') + .set('X-Service-Token', serviceToken) + .send({ + userId: '12345', + accountSequence: 1001, + publicKey: '02' + 'a'.repeat(64), + encryptedShareData: 'encrypted-share-data-base64', + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.shareId).toBeDefined(); + }); + + it('should reject duplicate share', async () => { + // First store + await request(app.getHttpServer()) + .post('/backup-share/store') + .set('X-Service-Token', serviceToken) + .send({ + userId: '99999', + accountSequence: 9999, + publicKey: '02' + 'b'.repeat(64), + encryptedShareData: 'data', + }); + + // Duplicate + await request(app.getHttpServer()) + .post('/backup-share/store') + .set('X-Service-Token', serviceToken) + .send({ + userId: '99999', + accountSequence: 9999, + publicKey: '02' + 'b'.repeat(64), + encryptedShareData: 'data', + }) + .expect(400); + }); + + it('should reject unauthorized service', async () => { + await request(app.getHttpServer()) + .post('/backup-share/store') + .set('X-Service-Token', 'invalid-token') + .send({}) + .expect(401); + }); + }); + + describe('POST /backup-share/retrieve', () => { + it('should retrieve backup share with valid recovery token', async () => { + // Setup: store a share first + const storeResponse = await request(app.getHttpServer()) + .post('/backup-share/store') + .set('X-Service-Token', serviceToken) + .send({ + userId: '77777', + accountSequence: 7777, + publicKey: '02' + 'c'.repeat(64), + encryptedShareData: 'test-encrypted-data', + }); + + // Retrieve + const response = await request(app.getHttpServer()) + .post('/backup-share/retrieve') + .set('X-Service-Token', serviceToken) + .send({ + userId: '77777', + publicKey: '02' + 'c'.repeat(64), + recoveryToken: 'valid-recovery-token', + }) + .expect(200); + + expect(response.body.encryptedShareData).toBeDefined(); + expect(response.body.partyIndex).toBe(2); + }); + }); +}); +``` + +--- + +## 10. 与 identity-service 的集成 + +### 10.1 修改 identity-service + +在 identity-service 的 `autoCreateAccount` 中添加调用 backup-service: + +```typescript +// identity-service/src/application/services/user-application.service.ts + +// 在保存服务端分片后,调用 backup-service 存储备份分片 +await this.mpcKeyShareRepository.saveServerShare({...}); + +// 新增: 调用 backup-service 存储备份分片 +if (mpcResult.backupShareData) { + try { + await this.backupClient.storeBackupShare({ + userId: account.userId.toString(), + accountSequence: account.accountSequence.value, + publicKey: mpcResult.publicKey, + encryptedShareData: mpcResult.backupShareData, + }); + this.logger.log(`Backup share stored to backup-service for user: ${account.userId}`); + } catch (error) { + this.logger.error(`Failed to store backup share: ${error.message}`); + // 根据业务需求决定是否回滚或继续 + // 建议: 记录失败,后续通过补偿任务重试 + } +} +``` + +### 10.2 配置更新 + +在 identity-service 的 `.env` 中添加: + +```bash +# Backup Service +BACKUP_SERVICE_URL="http://backup-server:3002" +SERVICE_JWT_SECRET="your-super-secret-service-jwt-key" +``` + +--- + +## 11. 监控和告警 + +### 11.1 关键指标 + +``` +- backup_share_store_total # 存储总数 +- backup_share_retrieve_total # 获取总数 +- backup_share_revoke_total # 撤销总数 +- backup_share_store_latency_ms # 存储延迟 +- backup_share_retrieve_latency_ms # 获取延迟 +- backup_share_error_total # 错误总数 +``` + +### 11.2 告警规则 + +```yaml +# 异常访问告警 +- alert: HighBackupShareRetrieveRate + expr: rate(backup_share_retrieve_total[5m]) > 10 + for: 5m + labels: + severity: warning + annotations: + summary: "Backup share retrieve rate is high" + +# 服务不可用告警 +- alert: BackupServiceDown + expr: up{job="backup-service"} == 0 + for: 1m + labels: + severity: critical +``` + +--- + +## 12. 开发步骤 + +### 12.1 初始化项目 + +```bash +# 1. 创建 NestJS 项目 +cd backend/services/backup-service +npx @nestjs/cli new . --skip-git + +# 2. 安装依赖 +npm install @nestjs/config @prisma/client class-validator class-transformer +npm install -D prisma + +# 3. 初始化 Prisma +npx prisma init + +# 4. 复制 schema 并生成客户端 +npx prisma generate +npx prisma migrate dev --name init +``` + +### 12.2 开发顺序 + +1. **基础设施层** - Prisma 配置、数据库连接 +2. **领域层** - BackupShare 实体、Repository 接口 +3. **应用层** - 存储/获取/撤销命令处理 +4. **接口层** - REST API Controller +5. **安全层** - ServiceAuthGuard、审计日志 +6. **测试** - 单元测试、E2E 测试 + +--- + +## 13. 检查清单 + +开发完成后,确保以下事项: + +- [ ] 数据库部署在独立服务器 +- [ ] 服务间认证已实现 +- [ ] 分片数据已二次加密 +- [ ] 审计日志已启用 +- [ ] 访问频率限制已实现 +- [ ] E2E 测试通过 +- [ ] 与 identity-service 集成测试通过 +- [ ] 监控指标已配置 +- [ ] 告警规则已配置 +- [ ] 生产环境部署在异地机房 diff --git a/backend/services/identity-service/.env.development b/backend/services/identity-service/.env.development index e00b0d6b..4429d445 100644 --- a/backend/services/identity-service/.env.development +++ b/backend/services/identity-service/.env.development @@ -27,3 +27,8 @@ APP_ENV="development" # Blockchain Encryption WALLET_ENCRYPTION_SALT="dev-wallet-salt" + +# MPC Service (NestJS 中间层) +# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go) +MPC_SERVICE_URL="http://localhost:3001" +MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service diff --git a/backend/services/identity-service/.env.example b/backend/services/identity-service/.env.example index cc9c2aa5..1ecc9faf 100644 --- a/backend/services/identity-service/.env.example +++ b/backend/services/identity-service/.env.example @@ -27,3 +27,8 @@ APP_ENV="development" # Blockchain Encryption WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production" + +# MPC Service (NestJS 中间层) +# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go) +MPC_SERVICE_URL="http://localhost:3001" +MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service diff --git a/backend/services/identity-service/package.json b/backend/services/identity-service/package.json index fb2f21d5..1a4bccc0 100644 --- a/backend/services/identity-service/package.json +++ b/backend/services/identity-service/package.json @@ -28,6 +28,7 @@ "prisma:studio": "prisma studio" }, "dependencies": { + "@nestjs/axios": "^3.0.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index 26a8a0f0..f0c12b31 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -69,22 +69,28 @@ model UserDevice { 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 - + publicKey String @map("public_key") @db.VarChar(130) // MPC公钥 (压缩/非压缩格式) + + // MPC 签名验证字段 - 防止地址被恶意篡改 + addressDigest String @map("address_digest") @db.VarChar(66) // SHA256摘要 + mpcSignatureR String @map("mpc_signature_r") @db.VarChar(66) // 签名R分量 + mpcSignatureS String @map("mpc_signature_s") @db.VarChar(66) // 签名S分量 + mpcSignatureV Int @map("mpc_signature_v") // 签名恢复ID + 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") + @@index([publicKey], name: "idx_public_key") @@map("wallet_addresses") } @@ -170,3 +176,53 @@ model SmsCode { @@index([expiresAt], name: "idx_sms_expires") @@map("sms_codes") } + +// MPC 密钥分片存储 - 服务端持有的分片 +model MpcKeyShare { + shareId BigInt @id @default(autoincrement()) @map("share_id") + userId BigInt @unique @map("user_id") // 一个用户只有一组MPC密钥 + + publicKey String @unique @map("public_key") @db.VarChar(130) // MPC公钥 + partyIndex Int @map("party_index") // 当前分片的参与方索引 (0=服务端) + threshold Int @default(2) // 阈值 t + totalParties Int @default(3) @map("total_parties") // 总参与方 n + + // 加密存储的分片数据 (AES加密) + encryptedShareData String @map("encrypted_share_data") @db.Text + + status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, ROTATED, REVOKED + + createdAt DateTime @default(now()) @map("created_at") + rotatedAt DateTime? @map("rotated_at") // 最后一次密钥轮换时间 + + @@index([publicKey], name: "idx_mpc_public_key") + @@index([status], name: "idx_mpc_status") + @@map("mpc_key_shares") +} + +// MPC 会话记录 - 用于审计和追踪 +model MpcSession { + sessionId String @id @map("session_id") @db.VarChar(50) + sessionType String @map("session_type") @db.VarChar(20) // KEYGEN, SIGNING, ROTATION + + userId BigInt? @map("user_id") + publicKey String? @map("public_key") @db.VarChar(130) + + status String @default("PENDING") @db.VarChar(20) // PENDING, IN_PROGRESS, COMPLETED, FAILED + errorMessage String? @map("error_message") @db.Text + + // 签名相关 (仅SIGNING类型) + messageHash String? @map("message_hash") @db.VarChar(66) + signatureR String? @map("signature_r") @db.VarChar(66) + signatureS String? @map("signature_s") @db.VarChar(66) + signatureV Int? @map("signature_v") + + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + @@index([sessionType], name: "idx_session_type") + @@index([userId], name: "idx_session_user") + @@index([status], name: "idx_session_status") + @@index([createdAt], name: "idx_session_created") + @@map("mpc_sessions") +} diff --git a/backend/services/identity-service/src/api/dto/index.ts b/backend/services/identity-service/src/api/dto/index.ts index 69d8003c..1344063a 100644 --- a/backend/services/identity-service/src/api/dto/index.ts +++ b/backend/services/identity-service/src/api/dto/index.ts @@ -125,16 +125,22 @@ export class AutoCreateAccountResponseDto { @ApiProperty() userId: string; - @ApiProperty() + @ApiProperty({ description: '账户序列号 (唯一标识,用于推荐和分享)' }) accountSequence: number; - @ApiProperty() + @ApiProperty({ description: '推荐码' }) referralCode: string; - @ApiProperty({ description: '助记词(仅返回一次,请妥善保管)' }) - mnemonic: string; + @ApiPropertyOptional({ description: '助记词 (MPC模式下为空)' }) + mnemonic?: string; - @ApiProperty() + @ApiPropertyOptional({ description: 'MPC客户端分片数据 (需安全存储,用于签名)' }) + clientShareData?: string; + + @ApiPropertyOptional({ description: 'MPC公钥' }) + publicKey?: string; + + @ApiProperty({ description: '三链钱包地址 (BSC/KAVA/DST)' }) walletAddresses: { kava: string; dst: string; bsc: string }; @ApiProperty() diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index 4c85a5ce..b100ffdf 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -134,7 +134,9 @@ export interface AutoCreateAccountResult { userId: string; accountSequence: number; referralCode: string; - mnemonic: string; + mnemonic: string; // 兼容字段,MPC模式下为空 + clientShareData?: string; // MPC 客户端分片数据 (需安全存储) + publicKey?: string; // MPC 公钥 walletAddresses: { kava: string; dst: string; bsc: string }; accessToken: string; refreshToken: string; diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index a6506bcf..29b8a20b 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -1,6 +1,8 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; +import { MpcKeyShareRepository, MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; +import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService, } from '@/domain/services'; @@ -12,6 +14,7 @@ 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 { MpcWalletService } from '@/infrastructure/external/mpc'; import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, @@ -24,22 +27,39 @@ import { @Injectable() export class UserApplicationService { + private readonly logger = new Logger(UserApplicationService.name); + constructor( @Inject(USER_ACCOUNT_REPOSITORY) private readonly userRepository: UserAccountRepository, + @Inject(MPC_KEY_SHARE_REPOSITORY) + private readonly mpcKeyShareRepository: MpcKeyShareRepository, private readonly sequenceGenerator: AccountSequenceGeneratorService, private readonly validatorService: UserValidatorService, private readonly walletGenerator: WalletGeneratorService, + private readonly mpcWalletService: MpcWalletService, private readonly tokenService: TokenService, private readonly redisService: RedisService, private readonly smsService: SmsService, private readonly eventPublisher: EventPublisherService, ) {} + /** + * 自动创建账户 (首次打开APP) + * + * 使用 MPC 2-of-3 协议生成钱包地址: + * - 生成三条链 (BSC/KAVA/DST) 的钱包地址 + * - 计算地址摘要并用 MPC 签名 + * - 签名存储在数据库中用于防止地址被篡改 + */ async autoCreateAccount(command: AutoCreateAccountCommand): Promise { + this.logger.log(`Creating account with MPC 2-of-3 for device: ${command.deviceId}`); + + // 1. 验证设备ID const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId); if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!); + // 2. 验证邀请码 let inviterSequence: AccountSequence | null = null; if (command.inviterReferralCode) { const referralCode = ReferralCode.create(command.inviterReferralCode); @@ -49,8 +69,10 @@ export class UserApplicationService { inviterSequence = inviter!.accountSequence; } + // 3. 生成账户序列号 const accountSequence = await this.sequenceGenerator.generateNext(); + // 4. 创建用户账户 const account = UserAccount.createAutomatic({ accountSequence, initialDeviceId: command.deviceId, @@ -60,29 +82,67 @@ export class UserApplicationService { city: CityCode.create(command.cityCode || 'DEFAULT'), }); - const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({ - userId: account.userId, + // 5. 使用 MPC 2-of-3 生成三链钱包地址 + this.logger.log(`Generating MPC wallet for account sequence: ${accountSequence.value}`); + const mpcResult = await this.mpcWalletService.generateMpcWallet({ + userId: account.userId.toString(), deviceId: command.deviceId, }); + // 6. 创建钱包地址实体 (包含 MPC 签名) + const wallets = new Map(); + for (const walletInfo of mpcResult.wallets) { + const chainType = walletInfo.chainType as ChainType; + const wallet = WalletAddress.createMpc({ + userId: account.userId, + chainType, + address: walletInfo.address, + publicKey: walletInfo.publicKey, + addressDigest: walletInfo.addressDigest, + signature: walletInfo.signature, + }); + wallets.set(chainType, wallet); + } + + // 7. 绑定钱包地址到账户 account.bindMultipleWalletAddresses(wallets); + + // 8. 保存账户和钱包 await this.userRepository.save(account); await this.userRepository.saveWallets(account.userId, Array.from(wallets.values())); + // 9. 保存服务端 MPC 分片到数据库 (用于后续签名) + await this.mpcKeyShareRepository.saveServerShare({ + userId: account.userId.value, + publicKey: mpcResult.publicKey, + partyIndex: 0, // SERVER = party 0 + threshold: 2, + totalParties: 3, + encryptedShareData: mpcResult.serverShareData, + }); + this.logger.log(`Server MPC share saved for user: ${account.userId.toString()}`); + + // 11. 生成 Token const tokens = await this.tokenService.generateTokenPair({ userId: account.userId.toString(), accountSequence: account.accountSequence.value, deviceId: command.deviceId, }); + // 12. 发布领域事件 await this.eventPublisher.publishAll(account.domainEvents); account.clearDomainEvents(); + this.logger.log(`Account created successfully: sequence=${accountSequence.value}, publicKey=${mpcResult.publicKey}`); + return { userId: account.userId.toString(), accountSequence: account.accountSequence.value, referralCode: account.referralCode.value, - mnemonic: mnemonic.value, + // MPC 模式下返回客户端分片数据 (而不是助记词) + mnemonic: '', // MPC 模式不使用助记词,返回空字符串 + clientShareData: mpcResult.clientShareData, // 客户端需要安全存储的分片数据 + publicKey: mpcResult.publicKey, // MPC 公钥 walletAddresses: { kava: wallets.get(ChainType.KAVA)!.address, dst: wallets.get(ChainType.DST)!.address, diff --git a/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts b/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts index 225498f8..49205825 100644 --- a/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts +++ b/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts @@ -13,12 +13,23 @@ import { MnemonicEncryption, } from '@/domain/value-objects'; +/** + * MPC 签名信息 + */ +export interface MpcSignature { + r: string; + s: string; + v: number; +} + export class WalletAddress { private readonly _addressId: AddressId; private readonly _userId: UserId; private readonly _chainType: ChainType; private readonly _address: string; - private readonly _encryptedMnemonic: string; + private readonly _publicKey: string; // MPC 公钥 + private readonly _addressDigest: string; // 地址摘要 + private readonly _mpcSignature: MpcSignature; // MPC 签名 private _status: AddressStatus; private readonly _boundAt: Date; @@ -26,7 +37,9 @@ export class WalletAddress { 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 publicKey(): string { return this._publicKey; } + get addressDigest(): string { return this._addressDigest; } + get mpcSignature(): MpcSignature { return this._mpcSignature; } get status(): AddressStatus { return this._status; } get boundAt(): Date { return this._boundAt; } @@ -35,7 +48,9 @@ export class WalletAddress { userId: UserId, chainType: ChainType, address: string, - encryptedMnemonic: string, + publicKey: string, + addressDigest: string, + mpcSignature: MpcSignature, status: AddressStatus, boundAt: Date, ) { @@ -43,13 +58,27 @@ export class WalletAddress { this._userId = userId; this._chainType = chainType; this._address = address; - this._encryptedMnemonic = encryptedMnemonic; + this._publicKey = publicKey; + this._addressDigest = addressDigest; + this._mpcSignature = mpcSignature; this._status = status; this._boundAt = boundAt; } - static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress { - if (!this.validateAddress(params.chainType, params.address)) { + /** + * 创建 MPC 钱包地址 + * + * @param params 包含 MPC 签名验证信息的参数 + */ + static createMpc(params: { + userId: UserId; + chainType: ChainType; + address: string; + publicKey: string; + addressDigest: string; + signature: MpcSignature; + }): WalletAddress { + if (!this.validateEvmAddress(params.address)) { throw new DomainError(`${params.chainType}地址格式错误`); } return new WalletAddress( @@ -57,37 +86,27 @@ export class WalletAddress { 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, + params.publicKey, + params.addressDigest, + params.signature, AddressStatus.ACTIVE, new Date(), ); } + /** + * 从数据库重建实体 + */ static reconstruct(params: { addressId: string; userId: string; chainType: ChainType; address: string; - encryptedMnemonic: string; + publicKey: string; + addressDigest: string; + mpcSignatureR: string; + mpcSignatureS: string; + mpcSignatureV: number; status: AddressStatus; boundAt: Date; }): WalletAddress { @@ -96,7 +115,13 @@ export class WalletAddress { UserId.create(params.userId), params.chainType, params.address, - params.encryptedMnemonic, + params.publicKey, + params.addressDigest, + { + r: params.mpcSignatureR, + s: params.mpcSignatureS, + v: params.mpcSignatureV, + }, params.status, params.boundAt, ); @@ -110,12 +135,95 @@ export class WalletAddress { this._status = AddressStatus.ACTIVE; } - decryptMnemonic(encryptionKey: string): Mnemonic { - if (!this._encryptedMnemonic) { - throw new DomainError('该地址没有加密助记词'); + /** + * 验证签名是否有效 + * 用于检测地址是否被篡改 + */ + async verifySignature(): Promise { + try { + const { ethers } = await import('ethers'); + + // 计算预期的摘要 + const expectedDigest = this.computeDigest(); + if (expectedDigest !== this._addressDigest) { + return false; + } + + // 验证签名 + const digestBytes = Buffer.from(this._addressDigest, 'hex'); + const sig = ethers.Signature.from({ + r: '0x' + this._mpcSignature.r, + s: '0x' + this._mpcSignature.s, + v: this._mpcSignature.v + 27, + }); + + const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); + const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true); + + return compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase(); + } catch { + return false; } - const mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey); - return Mnemonic.create(mnemonicStr); + } + + /** + * 计算地址摘要 + */ + private computeDigest(): string { + const message = `${this._chainType}:${this._address.toLowerCase()}`; + return createHash('sha256').update(message).digest('hex'); + } + + /** + * 验证 EVM 地址格式 + */ + private static validateEvmAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address); + } + + // ==================== 兼容旧版本的方法 (保留但标记为废弃) ==================== + + /** + * @deprecated 请使用 createMpc 方法 + */ + 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, + '', + '', + { r: '', s: '', v: 0 }, + AddressStatus.ACTIVE, + new Date(), + ); + } + + /** + * @deprecated MPC 模式下不再使用助记词 + */ + static createFromMnemonic(params: { + userId: UserId; + chainType: ChainType; + mnemonic: Mnemonic; + encryptionKey: string; + }): WalletAddress { + const address = this.deriveAddress(params.chainType, params.mnemonic); + return new WalletAddress( + AddressId.generate(), + params.userId, + params.chainType, + address, + '', + '', + { r: '', s: '', v: 0 }, + AddressStatus.ACTIVE, + new Date(), + ); } private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string { diff --git a/backend/services/identity-service/src/domain/repositories/mpc-key-share.repository.interface.ts b/backend/services/identity-service/src/domain/repositories/mpc-key-share.repository.interface.ts new file mode 100644 index 00000000..4931f734 --- /dev/null +++ b/backend/services/identity-service/src/domain/repositories/mpc-key-share.repository.interface.ts @@ -0,0 +1,52 @@ +import { UserId } from '@/domain/value-objects'; + +export interface MpcKeyShareData { + userId: bigint; + publicKey: string; + partyIndex: number; + threshold: number; + totalParties: number; + encryptedShareData: string; +} + +export interface MpcKeyShare { + shareId: bigint; + userId: bigint; + publicKey: string; + partyIndex: number; + threshold: number; + totalParties: number; + encryptedShareData: string; + status: string; + createdAt: Date; + rotatedAt: Date | null; +} + +export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY'); + +export interface MpcKeyShareRepository { + /** + * 保存服务端 MPC 分片 + */ + saveServerShare(data: MpcKeyShareData): Promise; + + /** + * 根据用户ID查找分片 + */ + findByUserId(userId: UserId): Promise; + + /** + * 根据公钥查找分片 + */ + findByPublicKey(publicKey: string): Promise; + + /** + * 更新分片状态 (用于密钥轮换) + */ + updateStatus(shareId: bigint, status: string): Promise; + + /** + * 轮换分片 (更新分片数据) + */ + rotateShare(shareId: bigint, newEncryptedData: string): Promise; +} diff --git a/backend/services/identity-service/src/infrastructure/external/mpc/index.ts b/backend/services/identity-service/src/infrastructure/external/mpc/index.ts new file mode 100644 index 00000000..1a51eb9a --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/mpc/index.ts @@ -0,0 +1,3 @@ +export * from './mpc.module'; +export * from './mpc-client.service'; +export * from './mpc-wallet.service'; diff --git a/backend/services/identity-service/src/infrastructure/external/mpc/mpc-client.service.ts b/backend/services/identity-service/src/infrastructure/external/mpc/mpc-client.service.ts new file mode 100644 index 00000000..bb52bdd8 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/mpc/mpc-client.service.ts @@ -0,0 +1,269 @@ +/** + * MPC Client Service + * + * 与 mpc-service (NestJS) 通信的客户端服务 + * + * 调用路径: + * identity-service → mpc-service (NestJS) → mpc-system (Go) + * + * 这种架构的优点: + * 1. 符合 DDD 边界上下文分离原则 + * 2. mpc-service 可以封装 MPC 业务逻辑、重试、熔断等 + * 3. 多个服务可共享 mpc-service(如 transaction-service) + * 4. MPC API Key 只需配置在 mpc-service + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { createHash, randomUUID } from 'crypto'; + +export interface KeygenRequest { + sessionId: string; + threshold: number; // t in t-of-n (默认 2) + totalParties: number; // n in t-of-n (默认 3) + parties: PartyInfo[]; +} + +export interface PartyInfo { + partyId: string; + partyIndex: number; + partyType: 'SERVER' | 'CLIENT' | 'BACKUP'; +} + +export interface KeygenResult { + sessionId: string; + publicKey: string; // 压缩格式公钥 (33 bytes hex) + publicKeyUncompressed: string; // 非压缩格式公钥 (65 bytes hex) + partyShares: PartyShareResult[]; +} + +export interface PartyShareResult { + partyId: string; + partyIndex: number; + encryptedShareData: string; // 加密的分片数据 +} + +export interface SigningRequest { + sessionId: string; + publicKey: string; + messageHash: string; // 32 bytes hex + signerParties: PartyInfo[]; + threshold: number; +} + +export interface SigningResult { + sessionId: string; + signature: { + r: string; // 32 bytes hex + s: string; // 32 bytes hex + v: number; // recovery id (0 or 1) + }; + messageHash: string; +} + +@Injectable() +export class MpcClientService { + private readonly logger = new Logger(MpcClientService.name); + private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL + private readonly mpcMode: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + // 连接 mpc-service (NestJS) 而不是直接连接 mpc-system (Go) + this.mpcServiceUrl = this.configService.get('MPC_SERVICE_URL', 'http://localhost:3001'); + this.mpcMode = this.configService.get('MPC_MODE', 'local'); + } + + /** + * 生成新的会话ID + */ + generateSessionId(): string { + return `mpc-${randomUUID()}`; + } + + /** + * 执行 2-of-3 MPC 密钥生成 + * + * 三个参与方: + * - Party 0 (SERVER): 服务端持有,用于常规签名 + * - Party 1 (CLIENT): 用户设备持有,返回给客户端 + * - Party 2 (BACKUP): 备份服务持有,用于恢复 + * + * 调用路径: identity-service → mpc-service → mpc-system (Go) + */ + async executeKeygen(request: KeygenRequest): Promise { + this.logger.log(`Starting MPC keygen: session=${request.sessionId}, t=${request.threshold}, n=${request.totalParties}`); + + // 开发模式使用本地模拟 + if (this.mpcMode === 'local') { + return this.executeLocalKeygen(request); + } + + try { + // 调用 mpc-service 的 keygen 同步接口 + const response = await firstValueFrom( + this.httpService.post( + `${this.mpcServiceUrl}/mpc-party/keygen/participate-sync`, + { + sessionId: request.sessionId, + partyId: 'server-party', + joinToken: this.generateJoinToken(request.sessionId), + shareType: 'SERVER', + userId: request.parties.find(p => p.partyType === 'CLIENT')?.partyId, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 300000, // 5分钟超时 + }, + ), + ); + + this.logger.log(`MPC keygen completed: session=${request.sessionId}, publicKey=${response.data.publicKey}`); + return response.data; + } catch (error) { + this.logger.error(`MPC keygen failed: session=${request.sessionId}`, error); + throw new Error(`MPC keygen failed: ${error.message}`); + } + } + + /** + * 生成加入会话的 token + */ + private generateJoinToken(sessionId: string): string { + return createHash('sha256').update(`${sessionId}-${Date.now()}`).digest('hex'); + } + + /** + * 执行 MPC 签名 + * + * 至少需要 threshold 个参与方来完成签名 + * 调用路径: identity-service → mpc-service → mpc-system (Go) + */ + async executeSigning(request: SigningRequest): Promise { + this.logger.log(`Starting MPC signing: session=${request.sessionId}, messageHash=${request.messageHash}`); + + // 开发模式使用本地模拟 + if (this.mpcMode === 'local') { + return this.executeLocalSigning(request); + } + + try { + // 调用 mpc-service 的 signing 同步接口 + const response = await firstValueFrom( + this.httpService.post( + `${this.mpcServiceUrl}/mpc-party/signing/participate-sync`, + { + sessionId: request.sessionId, + partyId: 'server-party', + joinToken: this.generateJoinToken(request.sessionId), + messageHash: request.messageHash, + publicKey: request.publicKey, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 120000, // 2分钟超时 + }, + ), + ); + + this.logger.log(`MPC signing completed: session=${request.sessionId}`); + return response.data; + } catch (error) { + this.logger.error(`MPC signing failed: session=${request.sessionId}`, error); + throw new Error(`MPC signing failed: ${error.message}`); + } + } + + /** + * 执行本地模拟的 MPC keygen (用于开发测试) + * + * 注意: 这是一个简化的本地实现,仅用于测试 + * 生产环境应该调用真正的 MPC 系统 + */ + async executeLocalKeygen(request: KeygenRequest): Promise { + this.logger.log(`Starting LOCAL MPC keygen (test mode): session=${request.sessionId}`); + + // 使用 ethers 生成密钥对 (简化版本,非真正的 MPC) + const { ethers } = await import('ethers'); + + // 生成随机私钥 + const wallet = ethers.Wallet.createRandom(); + const privateKey = wallet.privateKey; + const publicKey = wallet.publicKey; + + // 压缩公钥 (33 bytes) + const compressedPubKey = ethers.SigningKey.computePublicKey(publicKey, true); + + // 模拟分片数据 (实际上是完整私钥的加密版本,仅用于测试) + const partyShares: PartyShareResult[] = request.parties.map((party) => ({ + partyId: party.partyId, + partyIndex: party.partyIndex, + encryptedShareData: this.encryptShareData(privateKey, party.partyId), + })); + + return { + sessionId: request.sessionId, + publicKey: compressedPubKey.slice(2), // 去掉 0x 前缀 + publicKeyUncompressed: publicKey.slice(2), + partyShares, + }; + } + + /** + * 执行本地模拟的 MPC 签名 (用于开发测试) + */ + async executeLocalSigning(request: SigningRequest): Promise { + this.logger.log(`Starting LOCAL MPC signing (test mode): session=${request.sessionId}`); + + const { ethers } = await import('ethers'); + + // 从第一个参与方获取分片数据并解密 (测试模式) + // 实际生产环境需要多方协作签名 + const signerParty = request.signerParties[0]; + + // 这里我们无法真正恢复私钥,所以使用一个测试签名 + // 生产环境应该使用真正的 MPC 协议 + const messageHashBytes = Buffer.from(request.messageHash, 'hex'); + + // 创建一个临时钱包用于签名 (仅测试) + const testWallet = new ethers.Wallet( + '0x' + createHash('sha256').update(request.publicKey).digest('hex'), + ); + + const signature = testWallet.signingKey.sign(messageHashBytes); + + return { + sessionId: request.sessionId, + signature: { + r: signature.r.slice(2), + s: signature.s.slice(2), + v: signature.v - 27, // 转换为 0 或 1 + }, + messageHash: request.messageHash, + }; + } + + /** + * 加密分片数据 (简化版本) + */ + private encryptShareData(data: string, key: string): string { + // 简化的加密,实际应该使用 AES-GCM + const cipher = createHash('sha256').update(key).digest('hex'); + return Buffer.from(data).toString('base64') + '.' + cipher.slice(0, 16); + } + + /** + * 计算消息摘要 (用于签名) + */ + computeMessageHash(message: string): string { + return createHash('sha256').update(message).digest('hex'); + } +} diff --git a/backend/services/identity-service/src/infrastructure/external/mpc/mpc-wallet.service.ts b/backend/services/identity-service/src/infrastructure/external/mpc/mpc-wallet.service.ts new file mode 100644 index 00000000..77c447b7 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/mpc/mpc-wallet.service.ts @@ -0,0 +1,291 @@ +/** + * MPC Wallet Service + * + * 使用 MPC 2-of-3 协议生成三链钱包地址 + * 并对地址进行签名验证 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHash } from 'crypto'; +import { MpcClientService, KeygenResult, SigningResult } from './mpc-client.service'; + +export interface MpcWalletGenerationParams { + userId: string; + deviceId: string; +} + +export interface ChainWalletInfo { + chainType: 'KAVA' | 'DST' | 'BSC'; + address: string; + publicKey: string; + addressDigest: string; + signature: { + r: string; + s: string; + v: number; + }; +} + +export interface MpcWalletGenerationResult { + publicKey: string; // MPC 公钥 + serverShareData: string; // 服务端分片 (加密后存储) + clientShareData: string; // 客户端分片 (返回给用户设备) + backupShareData: string; // 备份分片 (存储在备份服务) + wallets: ChainWalletInfo[]; // 三条链的钱包信息 + sessionId: string; // MPC 会话ID +} + +@Injectable() +export class MpcWalletService { + private readonly logger = new Logger(MpcWalletService.name); + private readonly useLocalMpc: boolean; + + // 三条链的地址生成配置 + private readonly chainConfigs = { + BSC: { + name: 'Binance Smart Chain', + prefix: '0x', + derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链 + addressType: 'evm' as const, + }, + KAVA: { + name: 'Kava EVM', + prefix: '0x', + derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址 + addressType: 'evm' as const, + }, + DST: { + name: 'Durian Star Token', + prefix: 'dst', // Cosmos Bech32 前缀 + derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径 + addressType: 'cosmos' as const, + }, + }; + + constructor( + private readonly mpcClient: MpcClientService, + private readonly configService: ConfigService, + ) { + // 开发环境使用本地模拟 MPC + this.useLocalMpc = this.configService.get('MPC_MODE', 'local') === 'local'; + } + + /** + * 使用 MPC 2-of-3 生成三链钱包 + * + * 流程: + * 1. 生成 MPC 密钥 (2-of-3) + * 2. 从公钥派生三条链的地址 + * 3. 计算地址摘要 + * 4. 使用 MPC 签名对摘要进行签名 + * 5. 返回完整的钱包信息 + */ + async generateMpcWallet(params: MpcWalletGenerationParams): Promise { + this.logger.log(`Generating MPC wallet for user=${params.userId}, device=${params.deviceId}`); + + // Step 1: 生成 MPC 密钥 + const sessionId = this.mpcClient.generateSessionId(); + const keygenResult = await this.executeKeygen(sessionId, params); + + this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`); + + // Step 2: 从公钥派生三条链的地址 + const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey); + + // Step 3: 计算地址摘要 + const addressDigest = this.computeAddressDigest(walletAddresses); + + // Step 4: 使用 MPC 签名对摘要进行签名 + const signingSessionId = this.mpcClient.generateSessionId(); + const signingResult = await this.executeSigning( + signingSessionId, + keygenResult.publicKey, + addressDigest, + ); + + this.logger.log(`MPC signing completed: r=${signingResult.signature.r}`); + + // Step 5: 构建钱包信息 + const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({ + chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC', + address: wa.address, + publicKey: keygenResult.publicKey, + addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType), + signature: signingResult.signature, + })); + + // 提取各方分片 + const serverShare = keygenResult.partyShares.find((s) => s.partyIndex === 0); + const clientShare = keygenResult.partyShares.find((s) => s.partyIndex === 1); + const backupShare = keygenResult.partyShares.find((s) => s.partyIndex === 2); + + return { + publicKey: keygenResult.publicKey, + serverShareData: serverShare?.encryptedShareData || '', + clientShareData: clientShare?.encryptedShareData || '', + backupShareData: backupShare?.encryptedShareData || '', + wallets, + sessionId, + }; + } + + /** + * 验证钱包地址签名 + * + * 用于检测地址是否被篡改 + */ + async verifyWalletSignature( + address: string, + chainType: string, + publicKey: string, + signature: { r: string; s: string; v: number }, + ): Promise { + try { + const { ethers } = await import('ethers'); + + // 计算地址摘要 + const digest = this.computeSingleAddressDigest(address, chainType); + const digestBytes = Buffer.from(digest, 'hex'); + + // 重建签名 + const sig = ethers.Signature.from({ + r: '0x' + signature.r, + s: '0x' + signature.s, + v: signature.v + 27, + }); + + // 从签名恢复公钥 + const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig); + + // 压缩公钥并比较 + const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true); + + return compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase(); + } catch (error) { + this.logger.error(`Signature verification failed: ${error.message}`); + return false; + } + } + + /** + * 执行 MPC 密钥生成 + */ + private async executeKeygen(sessionId: string, params: MpcWalletGenerationParams): Promise { + const request = { + sessionId, + threshold: 2, + totalParties: 3, + parties: [ + { partyId: `server-${params.userId}`, partyIndex: 0, partyType: 'SERVER' as const }, + { partyId: `client-${params.deviceId}`, partyIndex: 1, partyType: 'CLIENT' as const }, + { partyId: `backup-${params.userId}`, partyIndex: 2, partyType: 'BACKUP' as const }, + ], + }; + + if (this.useLocalMpc) { + return this.mpcClient.executeLocalKeygen(request); + } + return this.mpcClient.executeKeygen(request); + } + + /** + * 执行 MPC 签名 + */ + private async executeSigning( + sessionId: string, + publicKey: string, + messageHash: string, + ): Promise { + const request = { + sessionId, + publicKey, + messageHash, + threshold: 2, + signerParties: [ + { partyId: 'server', partyIndex: 0, partyType: 'SERVER' as const }, + { partyId: 'backup', partyIndex: 2, partyType: 'BACKUP' as const }, + ], + }; + + if (this.useLocalMpc) { + return this.mpcClient.executeLocalSigning(request); + } + return this.mpcClient.executeSigning(request); + } + + /** + * 从 MPC 公钥派生三条链的地址 + * + * - BSC/KAVA: EVM 地址 (keccak256) + * - DST: Cosmos Bech32 地址 (ripemd160(sha256)) + */ + private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> { + const { ethers } = await import('ethers'); + const { bech32 } = await import('bech32'); + + // MPC 公钥 (压缩格式,33 bytes) + const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey; + const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex'); + + // 解压公钥 (如果是压缩格式) + let uncompressedPubKey: string; + if (pubKeyHex.length === 68) { + // 压缩格式 (33 bytes = 66 hex chars + 0x) + uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false); + } else { + uncompressedPubKey = pubKeyHex; + } + + // ===== EVM 地址派生 (BSC, KAVA) ===== + // 地址 = keccak256(公钥[1:])[12:] + const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀 + const addressHash = ethers.keccak256(pubKeyBytes); + const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40)); + + // ===== Cosmos 地址派生 (DST) ===== + // 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey))) + const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest(); + const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest(); + const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash)); + + return [ + { chainType: 'BSC', address: evmAddress }, + { chainType: 'KAVA', address: evmAddress }, + { chainType: 'DST', address: dstAddress }, + ]; + } + + /** + * 计算三个地址的联合摘要 + * + * digest = SHA256(BSC地址 + KAVA地址 + DST地址) + */ + private computeAddressDigest(addresses: { chainType: string; address: string }[]): string { + // 按链类型排序以确保一致性 + const sortedAddresses = [...addresses].sort((a, b) => + a.chainType.localeCompare(b.chainType), + ); + + // 拼接地址 + const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join(''); + + // 计算 SHA256 摘要 + return createHash('sha256').update(concatenated).digest('hex'); + } + + /** + * 计算单个地址的摘要 + */ + private computeSingleAddressDigest(address: string, chainType: string): string { + const message = `${chainType}:${address.toLowerCase()}`; + return createHash('sha256').update(message).digest('hex'); + } + + /** + * 获取所有支持的链类型 + */ + getSupportedChains(): string[] { + return Object.keys(this.chainConfigs); + } +} diff --git a/backend/services/identity-service/src/infrastructure/external/mpc/mpc.module.ts b/backend/services/identity-service/src/infrastructure/external/mpc/mpc.module.ts new file mode 100644 index 00000000..9da9c2e4 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/mpc/mpc.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { MpcWalletService } from './mpc-wallet.service'; +import { MpcClientService } from './mpc-client.service'; + +@Module({ + imports: [ + HttpModule.register({ + timeout: 300000, // MPC 操作可能需要较长时间 + maxRedirects: 5, + }), + ], + providers: [MpcWalletService, MpcClientService], + exports: [MpcWalletService, MpcClientService], +}) +export class MpcModule {} diff --git a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts index 4c53bcb2..1a39019b 100644 --- a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts @@ -1,31 +1,53 @@ import { Module, Global } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { PrismaService } from './persistence/prisma/prisma.service'; import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl'; +import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.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'; +import { MpcClientService, MpcWalletService } from './external/mpc'; +import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface'; @Global() @Module({ + imports: [ + HttpModule.register({ + timeout: 300000, + maxRedirects: 5, + }), + ], providers: [ PrismaService, UserAccountRepositoryImpl, + { + provide: MPC_KEY_SHARE_REPOSITORY, + useClass: MpcKeyShareRepositoryImpl, + }, UserAccountMapper, RedisService, EventPublisherService, SmsService, WalletGeneratorServiceImpl, + MpcClientService, + MpcWalletService, ], exports: [ PrismaService, UserAccountRepositoryImpl, + { + provide: MPC_KEY_SHARE_REPOSITORY, + useClass: MpcKeyShareRepositoryImpl, + }, UserAccountMapper, RedisService, EventPublisherService, SmsService, WalletGeneratorServiceImpl, + MpcClientService, + MpcWalletService, ], }) export class InfrastructureModule {} diff --git a/backend/services/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts b/backend/services/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts index d4a5b402..421946bf 100644 --- a/backend/services/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts +++ b/backend/services/identity-service/src/infrastructure/persistence/entities/wallet-address.entity.ts @@ -3,7 +3,11 @@ export interface WalletAddressEntity { userId: bigint; chainType: string; address: string; - encryptedMnemonic: string | null; + publicKey: string; + addressDigest: string; + mpcSignatureR: string; + mpcSignatureS: string; + mpcSignatureV: number; status: string; boundAt: Date; } diff --git a/backend/services/identity-service/src/infrastructure/persistence/repositories/mpc-key-share.repository.impl.ts b/backend/services/identity-service/src/infrastructure/persistence/repositories/mpc-key-share.repository.impl.ts new file mode 100644 index 00000000..34542db4 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/persistence/repositories/mpc-key-share.repository.impl.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { + MpcKeyShareRepository, + MpcKeyShareData, + MpcKeyShare, +} from '@/domain/repositories/mpc-key-share.repository.interface'; +import { UserId } from '@/domain/value-objects'; + +@Injectable() +export class MpcKeyShareRepositoryImpl implements MpcKeyShareRepository { + constructor(private readonly prisma: PrismaService) {} + + async saveServerShare(data: MpcKeyShareData): Promise { + const result = await this.prisma.mpcKeyShare.create({ + data: { + userId: data.userId, + publicKey: data.publicKey, + partyIndex: data.partyIndex, + threshold: data.threshold, + totalParties: data.totalParties, + encryptedShareData: data.encryptedShareData, + status: 'ACTIVE', + }, + }); + + return this.toDomain(result); + } + + async findByUserId(userId: UserId): Promise { + const result = await this.prisma.mpcKeyShare.findUnique({ + where: { userId: BigInt(userId.value) }, + }); + return result ? this.toDomain(result) : null; + } + + async findByPublicKey(publicKey: string): Promise { + const result = await this.prisma.mpcKeyShare.findUnique({ + where: { publicKey }, + }); + return result ? this.toDomain(result) : null; + } + + async updateStatus(shareId: bigint, status: string): Promise { + await this.prisma.mpcKeyShare.update({ + where: { shareId }, + data: { status }, + }); + } + + async rotateShare(shareId: bigint, newEncryptedData: string): Promise { + await this.prisma.mpcKeyShare.update({ + where: { shareId }, + data: { + encryptedShareData: newEncryptedData, + rotatedAt: new Date(), + }, + }); + } + + private toDomain(data: any): MpcKeyShare { + return { + shareId: data.shareId, + userId: data.userId, + publicKey: data.publicKey, + partyIndex: data.partyIndex, + threshold: data.threshold, + totalParties: data.totalParties, + encryptedShareData: data.encryptedShareData, + status: data.status, + createdAt: data.createdAt, + rotatedAt: data.rotatedAt, + }; + } +} diff --git a/backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts b/backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts index 27920fea..d1df08bb 100644 --- a/backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts +++ b/backend/services/identity-service/src/infrastructure/persistence/repositories/user-account.repository.impl.ts @@ -90,7 +90,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository { userId: BigInt(userId.value), chainType: w.chainType, address: w.address, - encryptedMnemonic: w.encryptedMnemonic, + publicKey: w.publicKey, + addressDigest: w.addressDigest, + mpcSignatureR: w.mpcSignature.r, + mpcSignatureS: w.mpcSignature.s, + mpcSignatureV: w.mpcSignature.v, status: w.status, boundAt: w.boundAt, })), @@ -205,7 +209,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository { userId: w.userId.toString(), chainType: w.chainType as ChainType, address: w.address, - encryptedMnemonic: w.encryptedMnemonic || '', + publicKey: w.publicKey || '', + addressDigest: w.addressDigest || '', + mpcSignatureR: w.mpcSignatureR || '', + mpcSignatureS: w.mpcSignatureS || '', + mpcSignatureV: w.mpcSignatureV || 0, status: w.status as AddressStatus, boundAt: w.boundAt, }), diff --git a/backend/services/identity-service/test/auto-create-account.e2e-spec.ts b/backend/services/identity-service/test/auto-create-account.e2e-spec.ts new file mode 100644 index 00000000..fe263ca2 --- /dev/null +++ b/backend/services/identity-service/test/auto-create-account.e2e-spec.ts @@ -0,0 +1,143 @@ +/** + * E2E 测试: 自动创建账号 (MPC 2-of-3) + * + * 测试流程: + * 1. 调用 POST /user/auto-create 创建账号 + * 2. 验证返回的账号信息 + * 3. 验证钱包地址格式 + * 4. 验证 MPC 分片数据 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('AutoCreateAccount (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /user/auto-create', () => { + it('should create account with MPC 2-of-3 wallet', async () => { + const deviceId = `test-device-${Date.now()}`; + + const response = await request(app.getHttpServer()) + .post('/user/auto-create') + .send({ + deviceId, + deviceName: 'Test Device', + }) + .expect(201); + + const body = response.body; + + // 验证基本字段 + expect(body).toHaveProperty('userId'); + expect(body).toHaveProperty('accountSequence'); + expect(body).toHaveProperty('referralCode'); + expect(body).toHaveProperty('accessToken'); + expect(body).toHaveProperty('refreshToken'); + expect(body).toHaveProperty('walletAddresses'); + + // 验证钱包地址格式 + // BSC 和 KAVA 是 EVM 兼容链,使用 0x 前缀的地址 + expect(body.walletAddresses.bsc).toMatch(/^0x[a-fA-F0-9]{40}$/); + expect(body.walletAddresses.kava).toMatch(/^0x[a-fA-F0-9]{40}$/); + // DST 是 Cosmos 链,使用 Bech32 格式 (dst1...) + expect(body.walletAddresses.dst).toMatch(/^dst1[a-z0-9]{38,58}$/); + + // BSC 和 KAVA 地址应该相同 (同一个 MPC 公钥派生的 EVM 地址) + expect(body.walletAddresses.bsc).toBe(body.walletAddresses.kava); + // DST 地址是不同格式,不应该与 EVM 地址相同 + expect(body.walletAddresses.dst).not.toBe(body.walletAddresses.bsc); + + // 验证 MPC 相关字段 + expect(body).toHaveProperty('clientShareData'); + expect(body).toHaveProperty('publicKey'); + expect(body.clientShareData).toBeTruthy(); + expect(body.publicKey).toBeTruthy(); + + // MPC 模式下 mnemonic 应该为空 + expect(body.mnemonic).toBe(''); + + // 验证账号序列号是正整数 + expect(typeof body.accountSequence).toBe('number'); + expect(body.accountSequence).toBeGreaterThan(0); + + // 验证推荐码格式 (假设是 6-10 位字母数字) + expect(body.referralCode).toMatch(/^[A-Z0-9]{6,10}$/); + + console.log('Created account:', { + userId: body.userId, + accountSequence: body.accountSequence, + referralCode: body.referralCode, + publicKey: body.publicKey?.substring(0, 20) + '...', + bscAddress: body.walletAddresses.bsc, + }); + }); + + it('should create different accounts for different devices', async () => { + const deviceId1 = `test-device-a-${Date.now()}`; + const deviceId2 = `test-device-b-${Date.now()}`; + + const [response1, response2] = await Promise.all([ + request(app.getHttpServer()) + .post('/user/auto-create') + .send({ deviceId: deviceId1 }), + request(app.getHttpServer()) + .post('/user/auto-create') + .send({ deviceId: deviceId2 }), + ]); + + // 两个账号应该有不同的序列号和钱包地址 + expect(response1.body.accountSequence).not.toBe(response2.body.accountSequence); + expect(response1.body.walletAddresses.bsc).not.toBe(response2.body.walletAddresses.bsc); + expect(response1.body.publicKey).not.toBe(response2.body.publicKey); + }); + + it('should reject invalid device id', async () => { + await request(app.getHttpServer()) + .post('/user/auto-create') + .send({ + deviceId: '', // 空设备ID + }) + .expect(400); + }); + + it('should handle inviter referral code', async () => { + // 先创建一个账号作为邀请人 + const inviterResponse = await request(app.getHttpServer()) + .post('/user/auto-create') + .send({ deviceId: `inviter-${Date.now()}` }) + .expect(201); + + const inviterReferralCode = inviterResponse.body.referralCode; + + // 使用邀请码创建新账号 + const inviteeResponse = await request(app.getHttpServer()) + .post('/user/auto-create') + .send({ + deviceId: `invitee-${Date.now()}`, + inviterReferralCode, + }) + .expect(201); + + expect(inviteeResponse.body.accountSequence).toBeGreaterThan( + inviterResponse.body.accountSequence, + ); + }); + }); +}); diff --git a/frontend/.claude/settings.local.json b/frontend/.claude/settings.local.json new file mode 100644 index 00000000..21e8189f --- /dev/null +++ b/frontend/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npx create-next-app:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/frontend/mobile-app/lib/core/di/injection_container.dart b/frontend/mobile-app/lib/core/di/injection_container.dart index 25762f77..786f8f1e 100644 --- a/frontend/mobile-app/lib/core/di/injection_container.dart +++ b/frontend/mobile-app/lib/core/di/injection_container.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../storage/secure_storage.dart'; import '../storage/local_storage.dart'; +import '../network/api_client.dart'; +import '../services/account_service.dart'; // Storage Providers final secureStorageProvider = Provider((ref) { @@ -11,6 +13,22 @@ final localStorageProvider = Provider((ref) { throw UnimplementedError('LocalStorage must be initialized before use'); }); +// API Client Provider +final apiClientProvider = Provider((ref) { + final secureStorage = ref.watch(secureStorageProvider); + return ApiClient(secureStorage: secureStorage); +}); + +// Account Service Provider +final accountServiceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + final secureStorage = ref.watch(secureStorageProvider); + return AccountService( + apiClient: apiClient, + secureStorage: secureStorage, + ); +}); + // Override provider with initialized instance ProviderContainer createProviderContainer(LocalStorage localStorage) { return ProviderContainer( diff --git a/frontend/mobile-app/lib/core/errors/exceptions.dart b/frontend/mobile-app/lib/core/errors/exceptions.dart index 30f3705f..5f907292 100644 --- a/frontend/mobile-app/lib/core/errors/exceptions.dart +++ b/frontend/mobile-app/lib/core/errors/exceptions.dart @@ -73,3 +73,40 @@ class StorageException implements Exception { @override String toString() => 'StorageException: $message'; } + +class ApiException implements Exception { + final String message; + final int? statusCode; + + const ApiException(this.message, {this.statusCode}); + + @override + String toString() => 'ApiException: $message (status: $statusCode)'; +} + +class UnauthorizedException implements Exception { + final String message; + + const UnauthorizedException([this.message = '未授权,请重新登录']); + + @override + String toString() => 'UnauthorizedException: $message'; +} + +class ForbiddenException implements Exception { + final String message; + + const ForbiddenException([this.message = '访问被拒绝']); + + @override + String toString() => 'ForbiddenException: $message'; +} + +class NotFoundException implements Exception { + final String message; + + const NotFoundException([this.message = '资源不存在']); + + @override + String toString() => 'NotFoundException: $message'; +} diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart new file mode 100644 index 00000000..274e6881 --- /dev/null +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -0,0 +1,268 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../storage/secure_storage.dart'; +import '../storage/storage_keys.dart'; +import '../errors/exceptions.dart'; + +/// API 客户端 +/// +/// 封装 Dio HTTP 客户端,提供: +/// - 自动 Token 注入 +/// - 错误处理 +/// - 请求/响应日志 +class ApiClient { + final Dio _dio; + final SecureStorage _secureStorage; + + ApiClient({ + required SecureStorage secureStorage, + String? baseUrl, + }) : _secureStorage = secureStorage, + _dio = Dio(BaseOptions( + baseUrl: baseUrl ?? _defaultBaseUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )) { + _setupInterceptors(); + } + + static const String _defaultBaseUrl = 'http://10.0.2.2:3001'; // Android 模拟器访问本机 + + /// 设置拦截器 + void _setupInterceptors() { + _dio.interceptors.add(InterceptorsWrapper( + onRequest: _onRequest, + onResponse: _onResponse, + onError: _onError, + )); + + // Debug 模式下添加日志 + if (kDebugMode) { + _dio.interceptors.add(LogInterceptor( + requestBody: true, + responseBody: true, + error: true, + )); + } + } + + /// 请求拦截器 - 自动注入 Token + Future _onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + // 不需要 Token 的接口 + final publicPaths = [ + '/user/auto-create', + '/user/login', + '/user/register', + '/user/send-sms-code', + '/user/recover-by-mnemonic', + '/user/recover-by-phone', + ]; + + final isPublic = publicPaths.any((path) => options.path.contains(path)); + + if (!isPublic) { + final token = await _secureStorage.read(key: StorageKeys.accessToken); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + } + + handler.next(options); + } + + /// 响应拦截器 + void _onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + handler.next(response); + } + + /// 错误拦截器 + Future _onError( + DioException error, + ErrorInterceptorHandler handler, + ) async { + // 401 错误尝试刷新 Token + if (error.response?.statusCode == 401) { + try { + final refreshed = await _tryRefreshToken(); + if (refreshed) { + // 重试原请求 + final response = await _retryRequest(error.requestOptions); + return handler.resolve(response); + } + } catch (e) { + // 刷新失败,清除登录状态 + await _clearAuthData(); + } + } + + handler.next(error); + } + + /// 尝试刷新 Token + Future _tryRefreshToken() async { + final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken); + if (refreshToken == null) return false; + + try { + final deviceId = await _secureStorage.read(key: StorageKeys.deviceId); + final response = await _dio.post( + '/user/auto-login', + data: { + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }, + options: Options(headers: {'Authorization': ''}), // 不带旧 Token + ); + + if (response.statusCode == 200) { + final data = response.data; + await _secureStorage.write( + key: StorageKeys.accessToken, + value: data['accessToken'], + ); + await _secureStorage.write( + key: StorageKeys.refreshToken, + value: data['refreshToken'], + ); + return true; + } + } catch (e) { + debugPrint('Token refresh failed: $e'); + } + return false; + } + + /// 重试请求 + Future _retryRequest(RequestOptions options) async { + final token = await _secureStorage.read(key: StorageKeys.accessToken); + options.headers['Authorization'] = 'Bearer $token'; + return _dio.fetch(options); + } + + /// 清除认证数据 + Future _clearAuthData() async { + await _secureStorage.delete(key: StorageKeys.accessToken); + await _secureStorage.delete(key: StorageKeys.refreshToken); + } + + /// GET 请求 + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.get( + path, + queryParameters: queryParameters, + options: options, + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + /// POST 请求 + Future> post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: options, + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + /// PUT 请求 + Future> put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.put( + path, + data: data, + queryParameters: queryParameters, + options: options, + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + /// DELETE 请求 + Future> delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + options: options, + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + /// 处理 Dio 错误 + Exception _handleDioError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException('网络连接超时,请稍后重试'); + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + final data = error.response?.data; + final message = data is Map ? data['message'] ?? '请求失败' : '请求失败'; + + switch (statusCode) { + case 400: + return ApiException(message, statusCode: 400); + case 401: + return UnauthorizedException(message); + case 403: + return ForbiddenException(message); + case 404: + return NotFoundException(message); + case 500: + case 502: + case 503: + return ServerException('服务器错误,请稍后重试'); + default: + return ApiException(message, statusCode: statusCode); + } + case DioExceptionType.cancel: + return ApiException('请求已取消'); + case DioExceptionType.connectionError: + return NetworkException('网络连接失败,请检查网络设置'); + default: + return NetworkException('网络错误: ${error.message}'); + } + } +} diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart new file mode 100644 index 00000000..95381f56 --- /dev/null +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -0,0 +1,286 @@ +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:uuid/uuid.dart'; +import '../network/api_client.dart'; +import '../storage/secure_storage.dart'; +import '../storage/storage_keys.dart'; +import '../errors/exceptions.dart'; + +/// 创建账号请求 +class CreateAccountRequest { + final String deviceId; + final String? deviceName; + final String? inviterReferralCode; + final String? provinceCode; + final String? cityCode; + + CreateAccountRequest({ + required this.deviceId, + this.deviceName, + this.inviterReferralCode, + this.provinceCode, + this.cityCode, + }); + + Map toJson() => { + 'deviceId': deviceId, + if (deviceName != null) 'deviceName': deviceName, + if (inviterReferralCode != null) + 'inviterReferralCode': inviterReferralCode, + if (provinceCode != null) 'provinceCode': provinceCode, + if (cityCode != null) 'cityCode': cityCode, + }; +} + +/// 创建账号响应 +class CreateAccountResponse { + final String userId; + final int accountSequence; + final String referralCode; + final String? mnemonic; + final String? clientShareData; // MPC 客户端分片数据 + final String? publicKey; // MPC 公钥 + final WalletAddresses walletAddresses; + final String accessToken; + final String refreshToken; + + CreateAccountResponse({ + required this.userId, + required this.accountSequence, + required this.referralCode, + this.mnemonic, + this.clientShareData, + this.publicKey, + required this.walletAddresses, + required this.accessToken, + required this.refreshToken, + }); + + factory CreateAccountResponse.fromJson(Map json) { + return CreateAccountResponse( + userId: json['userId'] as String, + accountSequence: json['accountSequence'] as int, + referralCode: json['referralCode'] as String, + mnemonic: json['mnemonic'] as String?, + clientShareData: json['clientShareData'] as String?, + publicKey: json['publicKey'] as String?, + walletAddresses: + WalletAddresses.fromJson(json['walletAddresses'] as Map), + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, + ); + } +} + +/// 钱包地址 +class WalletAddresses { + final String kava; + final String dst; + final String bsc; + + WalletAddresses({ + required this.kava, + required this.dst, + required this.bsc, + }); + + factory WalletAddresses.fromJson(Map json) { + return WalletAddresses( + kava: json['kava'] as String, + dst: json['dst'] as String, + bsc: json['bsc'] as String, + ); + } +} + +/// 账号服务 +/// +/// 处理账号创建、恢复等功能 +class AccountService { + final ApiClient _apiClient; + final SecureStorage _secureStorage; + + AccountService({ + required ApiClient apiClient, + required SecureStorage secureStorage, + }) : _apiClient = apiClient, + _secureStorage = secureStorage; + + /// 获取或生成设备ID + Future getOrCreateDeviceId() async { + // 尝试从存储读取 + var deviceId = await _secureStorage.read(key: StorageKeys.deviceId); + if (deviceId != null && deviceId.isNotEmpty) { + return deviceId; + } + + // 生成新的设备ID + deviceId = const Uuid().v4(); + await _secureStorage.write(key: StorageKeys.deviceId, value: deviceId); + return deviceId; + } + + /// 获取设备名称 + Future getDeviceName() async { + final deviceInfo = DeviceInfoPlugin(); + + if (Platform.isAndroid) { + final info = await deviceInfo.androidInfo; + return '${info.brand} ${info.model}'; + } else if (Platform.isIOS) { + final info = await deviceInfo.iosInfo; + return info.name; + } + + return 'Unknown Device'; + } + + /// 自动创建账号 (首次打开APP) + /// + /// 使用 MPC 2-of-3 协议生成钱包地址 + Future createAccount({ + String? inviterReferralCode, + String? provinceCode, + String? cityCode, + }) async { + try { + // 获取设备信息 + final deviceId = await getOrCreateDeviceId(); + final deviceName = await getDeviceName(); + + // 调用 API + final response = await _apiClient.post( + '/user/auto-create', + data: CreateAccountRequest( + deviceId: deviceId, + deviceName: deviceName, + inviterReferralCode: inviterReferralCode, + provinceCode: provinceCode, + cityCode: cityCode, + ).toJson(), + ); + + if (response.data == null) { + throw const ApiException('创建账号失败: 空响应'); + } + + final result = CreateAccountResponse.fromJson( + response.data as Map, + ); + + // 保存账号数据到安全存储 + await _saveAccountData(result, deviceId); + + return result; + } on ApiException { + rethrow; + } catch (e) { + throw ApiException('创建账号失败: $e'); + } + } + + /// 保存账号数据 + Future _saveAccountData( + CreateAccountResponse response, + String deviceId, + ) async { + // 保存基本信息 + await _secureStorage.write(key: StorageKeys.userId, value: response.userId); + await _secureStorage.write( + key: StorageKeys.accountSequence, + value: response.accountSequence.toString(), + ); + await _secureStorage.write( + key: StorageKeys.referralCode, + value: response.referralCode, + ); + + // 保存 Token + await _secureStorage.write( + key: StorageKeys.accessToken, + value: response.accessToken, + ); + await _secureStorage.write( + key: StorageKeys.refreshToken, + value: response.refreshToken, + ); + + // 保存钱包地址 + await _secureStorage.write( + key: StorageKeys.walletAddressBsc, + value: response.walletAddresses.bsc, + ); + await _secureStorage.write( + key: StorageKeys.walletAddressKava, + value: response.walletAddresses.kava, + ); + await _secureStorage.write( + key: StorageKeys.walletAddressDst, + value: response.walletAddresses.dst, + ); + + // 保存 MPC 数据 (如果有) + if (response.clientShareData != null && + response.clientShareData!.isNotEmpty) { + await _secureStorage.write( + key: StorageKeys.mpcClientShareData, + value: response.clientShareData!, + ); + } + if (response.publicKey != null && response.publicKey!.isNotEmpty) { + await _secureStorage.write( + key: StorageKeys.mpcPublicKey, + value: response.publicKey!, + ); + } + + // 保存助记词 (如果有) + if (response.mnemonic != null && response.mnemonic!.isNotEmpty) { + await _secureStorage.write( + key: StorageKeys.mnemonic, + value: response.mnemonic!, + ); + } + + // 标记钱包已创建 + await _secureStorage.write( + key: StorageKeys.isWalletCreated, + value: 'true', + ); + } + + /// 检查是否已创建账号 + Future hasAccount() async { + final isCreated = await _secureStorage.read(key: StorageKeys.isWalletCreated); + return isCreated == 'true'; + } + + /// 获取账号序列号 + Future getAccountSequence() async { + final sequence = await _secureStorage.read(key: StorageKeys.accountSequence); + return sequence != null ? int.tryParse(sequence) : null; + } + + /// 获取推荐码 + Future getReferralCode() async { + return _secureStorage.read(key: StorageKeys.referralCode); + } + + /// 获取钱包地址 + Future getWalletAddresses() async { + final bsc = await _secureStorage.read(key: StorageKeys.walletAddressBsc); + final kava = await _secureStorage.read(key: StorageKeys.walletAddressKava); + final dst = await _secureStorage.read(key: StorageKeys.walletAddressDst); + + if (bsc == null || kava == null || dst == null) { + return null; + } + + return WalletAddresses(kava: kava, dst: dst, bsc: bsc); + } + + /// 登出 + Future logout() async { + await _secureStorage.deleteAll(); + } +} diff --git a/frontend/mobile-app/lib/core/storage/storage_keys.dart b/frontend/mobile-app/lib/core/storage/storage_keys.dart index fef63b24..cb51228f 100644 --- a/frontend/mobile-app/lib/core/storage/storage_keys.dart +++ b/frontend/mobile-app/lib/core/storage/storage_keys.dart @@ -8,11 +8,22 @@ class StorageKeys { static const String isWalletCreated = 'is_wallet_created'; static const String isMnemonicBackedUp = 'is_mnemonic_backed_up'; + // MPC 相关 + static const String mpcClientShareData = 'mpc_client_share_data'; // MPC 客户端分片数据 + static const String mpcPublicKey = 'mpc_public_key'; // MPC 公钥 + static const String accountSequence = 'account_sequence'; // 账户序列号 + + // 钱包地址 + static const String walletAddressBsc = 'wallet_address_bsc'; + static const String walletAddressKava = 'wallet_address_kava'; + static const String walletAddressDst = 'wallet_address_dst'; + // User static const String userId = 'user_id'; static const String userProfile = 'user_profile'; static const String accessToken = 'access_token'; static const String refreshToken = 'refresh_token'; + static const String referralCode = 'referral_code'; // Settings static const String locale = 'locale'; @@ -20,6 +31,10 @@ class StorageKeys { static const String isFirstLaunch = 'is_first_launch'; static const String biometricEnabled = 'biometric_enabled'; + // Device + static const String deviceId = 'device_id'; + static const String deviceName = 'device_name'; + // Cache static const String lastSyncTime = 'last_sync_time'; static const String cachedRankingData = 'cached_ranking_data'; diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/backup_mnemonic_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/backup_mnemonic_page.dart index 756244a6..051f87fa 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/backup_mnemonic_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/backup_mnemonic_page.dart @@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart'; import '../../../../routes/route_paths.dart'; import '../../../../routes/app_router.dart'; -/// 备份助记词页面 - 显示生成的助记词和钱包地址 -/// 用户需要备份助记词后才能继续使用应用 +/// 备份助记词/账户信息页面 +/// MPC 模式下显示账户信息,传统模式下显示助记词 class BackupMnemonicPage extends ConsumerStatefulWidget { - /// 生成的助记词列表 + /// 生成的助记词列表 (MPC 模式下为空) final List mnemonicWords; /// KAVA 钱包地址 final String kavaAddress; @@ -16,8 +16,14 @@ class BackupMnemonicPage extends ConsumerStatefulWidget { final String dstAddress; /// BSC 钱包地址 final String bscAddress; - /// 序列号 + /// 序列号 (账户唯一标识) final String serialNumber; + /// 推荐码 + final String? referralCode; + /// MPC 公钥 + final String? publicKey; + /// 是否为 MPC 模式 + final bool isMpcMode; const BackupMnemonicPage({ super.key, @@ -26,6 +32,9 @@ class BackupMnemonicPage extends ConsumerStatefulWidget { required this.dstAddress, required this.bscAddress, required this.serialNumber, + this.referralCode, + this.publicKey, + this.isMpcMode = false, }); @override @@ -114,8 +123,11 @@ class _BackupMnemonicPageState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column( children: [ - // 助记词卡片 - _buildMnemonicCard(), + // MPC 模式显示账户信息卡片,否则显示助记词 + if (widget.isMpcMode) + _buildMpcInfoCard() + else if (widget.mnemonicWords.isNotEmpty) + _buildMnemonicCard(), const SizedBox(height: 24), // 警告提示 _buildWarningCard(), @@ -157,10 +169,10 @@ class _BackupMnemonicPageState extends ConsumerState { ), ), // 标题 - const Expanded( + Expanded( child: Text( - '备份你的助记词', - style: TextStyle( + widget.isMpcMode ? '账户创建成功' : '备份你的助记词', + style: const TextStyle( fontSize: 18, fontFamily: 'Inter', fontWeight: FontWeight.w700, @@ -332,26 +344,154 @@ class _BackupMnemonicPageState extends ConsumerState { ); } + /// 构建 MPC 信息卡片 (MPC 模式下显示) + Widget _buildMpcInfoCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 成功图标和标题 + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0x3322C55E), + borderRadius: BorderRadius.circular(24), + ), + child: const Icon( + Icons.check_circle, + color: Color(0xFF22C55E), + size: 28, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + '多方安全钱包已创建', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.25, + color: Color(0xFF5D4037), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // 说明文字 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0x1AD4AF37), + borderRadius: BorderRadius.circular(8), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MPC 2-of-3 多方安全计算', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + height: 1.5, + color: Color(0xFF8B5A2B), + ), + ), + SizedBox(height: 8), + Text( + '您的钱包密钥被安全地分成三份:\n' + '1. 服务器持有一份 (用于日常交易)\n' + '2. 您的设备持有一份 (已安全存储)\n' + '3. 备份服务持有一份 (用于恢复)', + style: TextStyle( + fontSize: 13, + fontFamily: 'Inter', + height: 1.6, + color: Color(0xFF5D4037), + ), + ), + ], + ), + ), + // 推荐码 (如果有) + if (widget.referralCode != null && widget.referralCode!.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.share, color: Color(0xFFD4AF37), size: 20), + const SizedBox(width: 8), + const Text( + '您的推荐码: ', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF5D4037), + ), + ), + Text( + widget.referralCode!, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Color(0xFFD4AF37), + ), + ), + const Spacer(), + GestureDetector( + onTap: () => _copyAddress(widget.referralCode!, '推荐码'), + child: const Icon(Icons.copy, color: Color(0xFF8B5A2B), size: 20), + ), + ], + ), + ], + ], + ), + ); + } + /// 构建警告卡片 Widget _buildWarningCard() { + final warningText = widget.isMpcMode + ? '您的账户已安全创建。序列号是您的唯一身份标识,请妥善保管。' + : '请妥善保管您的助记词,丢失将无法恢复账号。'; + return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0x1A7F1D1D), + color: widget.isMpcMode ? const Color(0x1A22C55E) : const Color(0x1A7F1D1D), borderRadius: BorderRadius.circular(8), border: Border.all( - color: const Color(0x337F1D1D), + color: widget.isMpcMode ? const Color(0x3322C55E) : const Color(0x337F1D1D), width: 1, ), ), - child: const Text( - '请妥善保管您的助记词,丢失将无法恢复账号。', + child: Text( + warningText, style: TextStyle( fontSize: 14, fontFamily: 'Inter', height: 1.5, - color: Color(0xFF991B1B), + color: widget.isMpcMode ? const Color(0xFF166534) : const Color(0xFF991B1B), ), textAlign: TextAlign.center, ), @@ -509,9 +649,9 @@ class _BackupMnemonicPageState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column( children: [ - // 确认备份按钮 + // MPC 模式直接进入主页,传统模式需要验证助记词 GestureDetector( - onTap: _confirmBackup, + onTap: widget.isMpcMode ? _enterApp : _confirmBackup, child: Container( width: double.infinity, height: 48, @@ -519,10 +659,10 @@ class _BackupMnemonicPageState extends ConsumerState { color: const Color(0xFFD4AF37), borderRadius: BorderRadius.circular(12), ), - child: const Center( + child: Center( child: Text( - '我已备份助记词', - style: TextStyle( + widget.isMpcMode ? '进入应用' : '我已备份助记词', + style: const TextStyle( fontSize: 16, fontFamily: 'Inter', fontWeight: FontWeight.w700, @@ -534,22 +674,29 @@ class _BackupMnemonicPageState extends ConsumerState { ), ), const SizedBox(height: 16), - // 返回上一步 - GestureDetector( - onTap: _goBack, - child: const Text( - '返回上一步', - style: TextStyle( - fontSize: 14, - fontFamily: 'Inter', - fontWeight: FontWeight.w500, - height: 1.5, - color: Color(0xCC8B5A2B), + // 返回上一步 (MPC 模式下不显示,因为账号已创建) + if (!widget.isMpcMode) + GestureDetector( + onTap: _goBack, + child: const Text( + '返回上一步', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + height: 1.5, + color: Color(0xCC8B5A2B), + ), ), ), - ), ], ), ); } + + /// MPC 模式下直接进入应用 + void _enterApp() { + // 跳转到主页 + context.go(RoutePaths.ranking); + } } diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/onboarding_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/onboarding_page.dart index 474abdc0..d2e8e35b 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/onboarding_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/onboarding_page.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../routes/route_paths.dart'; import '../../../../routes/app_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/account_service.dart'; /// 创建账号页面 - 用户首次进入应用时的引导页面 /// 提供创建钱包和导入助记词两种选项 @@ -19,51 +21,70 @@ class _OnboardingPageState extends ConsumerState { bool _isAgreed = false; // 创建钱包加载状态 bool _isCreating = false; + // 错误信息 + String? _errorMessage; - /// 创建钱包并跳转到备份助记词页面 + /// 创建钱包并跳转到备份页面 + /// + /// 调用后端 API 使用 MPC 2-of-3 协议生成钱包地址 Future _createWallet() async { if (!_isAgreed) { _showAgreementTip(); return; } - setState(() => _isCreating = true); + setState(() { + _isCreating = true; + _errorMessage = null; + }); try { - // TODO: 实现真正的钱包创建逻辑 (bip39) - // 这里模拟生成钱包的过程 - await Future.delayed(const Duration(seconds: 2)); + // 获取 AccountService + final accountService = ref.read(accountServiceProvider); - // 模拟生成的助记词 (12个单词) - final mockMnemonicWords = [ - 'apple', 'banana', 'cherry', 'date', - 'elder', 'fig', 'grape', 'honey', - 'kiwi', 'lemon', 'mango', 'nectar', - ]; - - // 模拟生成的钱包地址 - const mockKavaAddress = '0x1234567890abcdef1234567890abcdef12345678'; - const mockDstAddress = '0xABCDEF1234567890abcdef1234567890ABCDEFGH'; - const mockBscAddress = '0x9876543210zyxwvu9876543210zyxwvu98765432'; - const mockSerialNumber = '12345678'; + // 调用后端 API 创建账号 + debugPrint('开始创建账号...'); + final response = await accountService.createAccount(); + debugPrint('账号创建成功: 序列号=${response.accountSequence}'); if (!mounted) return; - // 跳转到备份助记词页面 + // MPC 模式下,检查是否有客户端分片数据 + // 如果有分片数据,需要提示用户妥善保管 + final hasMpcData = response.clientShareData != null && + response.clientShareData!.isNotEmpty; + + // 跳转到钱包创建成功页面 context.push( RoutePaths.backupMnemonic, extra: BackupMnemonicParams( - mnemonicWords: mockMnemonicWords, - kavaAddress: mockKavaAddress, - dstAddress: mockDstAddress, - bscAddress: mockBscAddress, - serialNumber: mockSerialNumber, + // MPC 模式下助记词为空,显示账号信息即可 + mnemonicWords: response.mnemonic?.isNotEmpty == true + ? response.mnemonic!.split(' ') + : [], // MPC 模式下为空 + kavaAddress: response.walletAddresses.kava, + dstAddress: response.walletAddresses.dst, + bscAddress: response.walletAddresses.bsc, + serialNumber: response.accountSequence.toString(), + referralCode: response.referralCode, + publicKey: response.publicKey, + isMpcMode: hasMpcData, // 标记是否为 MPC 模式 ), ); } catch (e) { + debugPrint('创建账号失败: $e'); if (!mounted) return; + + setState(() { + _errorMessage = e.toString(); + }); + ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('创建钱包失败: $e')), + SnackBar( + content: Text('创建账号失败: ${e.toString().replaceAll('Exception: ', '')}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), ); } finally { if (mounted) { diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index c18592ef..11f24ba0 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -27,6 +27,9 @@ class BackupMnemonicParams { final String dstAddress; final String bscAddress; final String serialNumber; + final String? referralCode; // 推荐码 + final String? publicKey; // MPC 公钥 + final bool isMpcMode; // 是否为 MPC 模式 BackupMnemonicParams({ required this.mnemonicWords, @@ -34,6 +37,9 @@ class BackupMnemonicParams { required this.dstAddress, required this.bscAddress, required this.serialNumber, + this.referralCode, + this.publicKey, + this.isMpcMode = false, }); } @@ -94,6 +100,9 @@ final appRouterProvider = Provider((ref) { dstAddress: params.dstAddress, bscAddress: params.bscAddress, serialNumber: params.serialNumber, + referralCode: params.referralCode, + publicKey: params.publicKey, + isMpcMode: params.isMpcMode, ); }, ), diff --git a/frontend/temp_backup/README.MD b/frontend/temp_backup/README.MD new file mode 100644 index 00000000..e69de29b diff --git a/frontend/temp_backup/RWADURIAN-ADMIN-WEB-SPEC.md b/frontend/temp_backup/RWADURIAN-ADMIN-WEB-SPEC.md new file mode 100644 index 00000000..5ab740b9 --- /dev/null +++ b/frontend/temp_backup/RWADURIAN-ADMIN-WEB-SPEC.md @@ -0,0 +1,1709 @@ +# 榴莲认种管理后台 (RWADurian Admin Web) - 前端开发规范文档 + +> **项目名称**: RWADurian Admin Web +> **版本**: 1.0.0 +> **目标路径**: `rwadurian/frontend/admin-web` +> **文档用途**: 供 Claude Code 阅读并指导完成项目开发 + +--- + +## 目录 + +1. [项目概述](#1-项目概述) +2. [技术架构](#2-技术架构) +3. [目录结构](#3-目录结构) +4. [功能模块详解](#4-功能模块详解) +5. [UI/UX 设计规范](#5-uiux-设计规范) +6. [组件设计指南](#6-组件设计指南) +7. [状态管理方案](#7-状态管理方案) +8. [API 集成规范](#8-api-集成规范) +9. [开发规范与约定](#9-开发规范与约定) +10. [开发优先级与里程碑](#10-开发优先级与里程碑) + +--- + +## 1. 项目概述 + +### 1.1 项目背景 + +榴莲认种管理后台是一个面向管理员的 Web 应用系统,用于管理榴莲树认种业务的全流程,包括用户管理、排名龙虎榜、省市公司授权、数据统计分析等核心功能。 + +### 1.2 核心功能模块 + +| 模块名称 | 路由路径 | 功能描述 | +|---------|---------|---------| +| 登录认证 | `/login` | 管理员登录、忘记密码、注册入口 | +| 仪表板 | `/dashboard` | 数据概览、趋势图表、最近活动 | +| 用户管理 | `/users` | 用户列表、搜索筛选、批量操作 | +| 龙虎榜管理 | `/leaderboard` | 日/周/月榜配置、虚拟排名设置 | +| 授权管理 | `/authorization` | 省/市公司授权、考核规则配置 | +| 数据统计 | `/statistics` | 多维度数据分析、报表导出 | +| 系统设置 | `/settings` | 结算参数、限额、安全配置 | +| 帮助中心 | `/help` | 文档、FAQ、联系支持 | + +### 1.3 目标用户 + +- **超级管理员**: 拥有全部权限,可管理其他管理员账号 +- **运营管理员**: 日常运营操作,无敏感设置权限 +- **数据分析员**: 只读权限,专注数据查看和报表导出 + +--- + +## 2. 技术架构 + +### 2.1 技术栈 + +```yaml +核心框架: + - React: 18.x + - Next.js: 15.x (App Router) + - TypeScript: 5.x + +状态管理: + - Redux Toolkit: 全局状态(用户认证、系统配置) + - Zustand: 局部状态(表单、UI临时状态) + +样式方案: + - 纯 CSS/SCSS Modules (无第三方 UI 库) + - CSS Variables 主题系统 + - Figma 导出的自定义设计 + +数据可视化: + - Recharts 或 ECharts (图表库) + - D3.js (复杂可视化需求) + +网络请求: + - Axios + React Query (TanStack Query) + +工具链: + - ESLint + Prettier + - Husky + lint-staged + - Jest + React Testing Library +``` + +### 2.2 Clean Architecture 分层 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ (Pages, Components, Hooks) │ +├─────────────────────────────────────────────────────────────┤ +│ Application Layer │ +│ (Use Cases, Services, State Management) │ +├─────────────────────────────────────────────────────────────┤ +│ Domain Layer │ +│ (Entities, Value Objects, Domain Services) │ +├─────────────────────────────────────────────────────────────┤ +│ Infrastructure Layer │ +│ (API Clients, Storage, External Services) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 目录结构 + +``` +admin-web/ +├── public/ +│ ├── assets/ +│ │ ├── images/ +│ │ │ ├── logo.svg +│ │ │ └── icons/ +│ │ └── fonts/ +│ └── locales/ +│ └── zh-CN.json +│ +├── src/ +│ ├── app/ # Next.js App Router +│ │ ├── (auth)/ +│ │ │ ├── login/ +│ │ │ │ └── page.tsx +│ │ │ ├── forgot-password/ +│ │ │ │ └── page.tsx +│ │ │ └── layout.tsx +│ │ │ +│ │ ├── (dashboard)/ +│ │ │ ├── dashboard/ +│ │ │ │ └── page.tsx +│ │ │ ├── users/ +│ │ │ │ ├── page.tsx +│ │ │ │ └── [id]/ +│ │ │ │ └── page.tsx +│ │ │ ├── leaderboard/ +│ │ │ │ └── page.tsx +│ │ │ ├── authorization/ +│ │ │ │ └── page.tsx +│ │ │ ├── statistics/ +│ │ │ │ └── page.tsx +│ │ │ ├── settings/ +│ │ │ │ └── page.tsx +│ │ │ ├── help/ +│ │ │ │ └── page.tsx +│ │ │ └── layout.tsx +│ │ │ +│ │ ├── layout.tsx +│ │ ├── page.tsx +│ │ └── globals.css +│ │ +│ ├── components/ # UI 组件 +│ │ ├── common/ # 通用基础组件 +│ │ │ ├── Button/ +│ │ │ │ ├── Button.tsx +│ │ │ │ ├── Button.module.scss +│ │ │ │ └── index.ts +│ │ │ ├── Input/ +│ │ │ ├── Select/ +│ │ │ ├── Table/ +│ │ │ ├── Modal/ +│ │ │ ├── Card/ +│ │ │ ├── Badge/ +│ │ │ ├── Avatar/ +│ │ │ ├── Pagination/ +│ │ │ ├── Toggle/ +│ │ │ ├── Dropdown/ +│ │ │ ├── Tabs/ +│ │ │ ├── Tooltip/ +│ │ │ ├── Loading/ +│ │ │ └── Toast/ +│ │ │ +│ │ ├── layout/ # 布局组件 +│ │ │ ├── Sidebar/ +│ │ │ ├── Header/ +│ │ │ ├── Breadcrumb/ +│ │ │ └── PageContainer/ +│ │ │ +│ │ ├── charts/ # 图表组件 +│ │ │ ├── LineChart/ +│ │ │ ├── BarChart/ +│ │ │ ├── PieChart/ +│ │ │ └── DonutChart/ +│ │ │ +│ │ └── features/ # 业务组件 +│ │ ├── dashboard/ +│ │ │ ├── StatCard/ +│ │ │ ├── TrendChart/ +│ │ │ ├── RegionDistribution/ +│ │ │ └── RecentActivity/ +│ │ ├── users/ +│ │ │ ├── UserTable/ +│ │ │ ├── UserFilters/ +│ │ │ └── UserDetailModal/ +│ │ ├── leaderboard/ +│ │ │ ├── RankingTable/ +│ │ │ ├── BoardSettings/ +│ │ │ └── VirtualRankPreview/ +│ │ ├── authorization/ +│ │ │ ├── CompanyTable/ +│ │ │ ├── AssessmentRules/ +│ │ │ └── LadderTargetTable/ +│ │ ├── statistics/ +│ │ │ ├── TrendAnalysis/ +│ │ │ ├── RegionStats/ +│ │ │ ├── CompanyOperations/ +│ │ │ └── RevenueDetails/ +│ │ ├── settings/ +│ │ │ ├── SettlementConfig/ +│ │ │ ├── LeaderboardConfig/ +│ │ │ ├── QuotaConfig/ +│ │ │ ├── AssessmentConfig/ +│ │ │ ├── DisplayConfig/ +│ │ │ └── SecurityConfig/ +│ │ └── help/ +│ │ ├── DocumentCard/ +│ │ ├── DocumentList/ +│ │ ├── FAQSection/ +│ │ └── ContactSupport/ +│ │ +│ ├── domain/ # 领域层 +│ │ ├── entities/ +│ │ │ ├── User.ts +│ │ │ ├── Company.ts +│ │ │ ├── Ranking.ts +│ │ │ ├── Statistics.ts +│ │ │ └── Settings.ts +│ │ ├── value-objects/ +│ │ │ ├── UserId.ts +│ │ │ ├── Province.ts +│ │ │ └── Currency.ts +│ │ └── interfaces/ +│ │ ├── IUserRepository.ts +│ │ ├── ICompanyRepository.ts +│ │ └── IStatisticsRepository.ts +│ │ +│ ├── application/ # 应用层 +│ │ ├── use-cases/ +│ │ │ ├── auth/ +│ │ │ │ ├── LoginUseCase.ts +│ │ │ │ └── LogoutUseCase.ts +│ │ │ ├── users/ +│ │ │ │ ├── GetUsersUseCase.ts +│ │ │ │ ├── UpdateUserUseCase.ts +│ │ │ │ └── ExportUsersUseCase.ts +│ │ │ ├── leaderboard/ +│ │ │ │ ├── GetRankingsUseCase.ts +│ │ │ │ └── UpdateBoardSettingsUseCase.ts +│ │ │ └── ... +│ │ │ +│ │ ├── services/ +│ │ │ ├── AuthService.ts +│ │ │ ├── UserService.ts +│ │ │ ├── LeaderboardService.ts +│ │ │ ├── AuthorizationService.ts +│ │ │ ├── StatisticsService.ts +│ │ │ └── SettingsService.ts +│ │ │ +│ │ └── dto/ +│ │ ├── UserDTO.ts +│ │ ├── CompanyDTO.ts +│ │ └── StatisticsDTO.ts +│ │ +│ ├── infrastructure/ # 基础设施层 +│ │ ├── api/ +│ │ │ ├── client.ts # Axios 实例配置 +│ │ │ ├── endpoints.ts # API 端点定义 +│ │ │ └── repositories/ +│ │ │ ├── UserRepository.ts +│ │ │ ├── CompanyRepository.ts +│ │ │ └── StatisticsRepository.ts +│ │ │ +│ │ ├── storage/ +│ │ │ ├── localStorage.ts +│ │ │ └── sessionStorage.ts +│ │ │ +│ │ └── external/ +│ │ └── exportService.ts # Excel 导出等 +│ │ +│ ├── store/ # 状态管理 +│ │ ├── redux/ +│ │ │ ├── store.ts +│ │ │ ├── rootReducer.ts +│ │ │ └── slices/ +│ │ │ ├── authSlice.ts +│ │ │ ├── settingsSlice.ts +│ │ │ └── notificationSlice.ts +│ │ │ +│ │ └── zustand/ +│ │ ├── useUserFiltersStore.ts +│ │ ├── useLeaderboardStore.ts +│ │ └── useModalStore.ts +│ │ +│ ├── hooks/ # 自定义 Hooks +│ │ ├── useAuth.ts +│ │ ├── useUsers.ts +│ │ ├── useLeaderboard.ts +│ │ ├── useStatistics.ts +│ │ ├── usePagination.ts +│ │ ├── useDebounce.ts +│ │ └── useExport.ts +│ │ +│ ├── utils/ # 工具函数 +│ │ ├── formatters.ts # 数字、日期格式化 +│ │ ├── validators.ts # 表单验证 +│ │ ├── constants.ts # 常量定义 +│ │ └── helpers.ts # 通用辅助函数 +│ │ +│ ├── styles/ # 全局样式 +│ │ ├── variables.scss # CSS 变量 +│ │ ├── mixins.scss # SCSS Mixins +│ │ ├── typography.scss # 字体样式 +│ │ ├── animations.scss # 动画定义 +│ │ └── reset.scss # 样式重置 +│ │ +│ └── types/ # TypeScript 类型 +│ ├── api.types.ts +│ ├── user.types.ts +│ ├── company.types.ts +│ ├── statistics.types.ts +│ └── common.types.ts +│ +├── .env.local +├── .env.development +├── .env.production +├── .eslintrc.json +├── .prettierrc +├── next.config.js +├── tsconfig.json +├── package.json +└── README.md +``` + +--- + +## 4. 功能模块详解 + +### 4.1 登录认证模块 (`/login`) + +#### 页面布局 +- 居中卡片式设计,浅灰色背景 (`#F5F5F5`) +- Logo + 系统名称 "榴莲认种管理后台" +- 欢迎语 "欢迎回来" + +#### 功能点 +| 功能 | 描述 | 交互 | +|-----|------|------| +| 邮箱输入 | 邮箱格式验证 | 失焦验证 | +| 密码输入 | 密码强度提示 | 实时验证 | +| 忘记密码 | 跳转密码重置页 | 链接跳转 | +| 登录按钮 | 提交登录请求 | Loading 状态 | +| 注册入口 | 跳转注册页面 | 链接跳转 | + +#### 表单验证规则 +```typescript +interface LoginForm { + email: string; // 必填,邮箱格式 + password: string; // 必填,6-20位 +} +``` + +--- + +### 4.2 仪表板模块 (`/dashboard`) + +#### 页面结构 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Sidebar] │ [Header: 面包屑 + 操作按钮 + 用户信息] │ +│ ├─────────────────────────────────────────────────┤ +│ │ [统计卡片区: 4个卡片横向排列] │ +│ ├───────────────────────────┬─────────────────────┤ +│ │ [认种趋势图] │ [最近活动] │ +│ ├───────────────────────────┤ │ +│ │ [月度增长图] │ │ +│ ├───────────────────────────┤ │ +│ │ [区域分布饼图] │ │ +└───────────┴───────────────────────────┴─────────────────────┘ +``` + +#### 统计卡片数据 +| 卡片名称 | 数据字段 | 增长指标 | +|---------|---------|---------| +| 总认种量 | `totalAdoptions` | 百分比增长 | +| 活跃用户 | `activeUsers` | 百分比增长 | +| 省级公司 | `provincialCompanies` | 百分比增长 | +| 市级公司 | `cityCompanies` | 百分比增长 | + +#### 图表组件 + +**认种趋势折线图** +```typescript +interface TrendChartData { + date: string; + value: number; +} +// 支持时间范围切换: 7天/30天/90天 +``` + +**月度增长柱状图** +```typescript +interface MonthlyGrowthData { + month: string; + adoptions: number; + growth: number; // 环比增长 +} +``` + +**区域分布环形图** +```typescript +interface RegionDistributionData { + region: string; + percentage: number; + color: string; +} +``` + +#### 最近活动列表 +```typescript +interface ActivityItem { + id: string; + type: 'user_register' | 'company_activity' | 'system_update' | 'report_generated'; + icon: string; + title: string; + description: string; + timestamp: string; // "5分钟前", "2小时前", "昨天" +} +``` + +#### 顶部操作按钮 +- **添加用户**: 打开添加用户弹窗 +- **授权管理**: 跳转授权管理页 +- **查看报表**: 跳转数据统计页(主按钮样式) + +--- + +### 4.3 用户管理模块 (`/users`) + +#### 页面功能 + +**搜索与筛选** +```typescript +interface UserFilters { + keyword: string; // 账户ID、昵称搜索 + advancedFilters: { + province?: string; // 省份筛选 + city?: string; // 城市筛选 + rankRange?: [number, number]; // 排名范围 + adoptionRange?: [number, number]; // 认种量范围 + status?: 'active' | 'inactive'; + }; +} +``` + +**用户表格列定义** +| 列名 | 字段 | 宽度 | 排序 | 说明 | +|-----|------|------|------|------| +| 复选框 | - | 48px | - | 批量选择 | +| 账户序号 | `accountId` | 100px | ✓ | 唯一标识 | +| 头像 | `avatar` | 60px | - | 圆形头像+在线状态指示 | +| 昵称 | `nickname` | 80px | ✓ | - | +| 账户认种量 | `personalAdoptions` | 100px | ✓ | - | +| 团队总注册地址量 | `teamAddresses` | 120px | ✓ | - | +| 团队总认种量 | `teamAdoptions` | 100px | ✓ | - | +| 团队本省认种量及占比 | `provincialAdoptions` | 140px | ✓ | 显示数量(占比%) | +| 团队本市认种量及占比 | `cityAdoptions` | 140px | ✓ | 显示数量(占比%) | +| 推荐人序列号 | `referrerId` | 100px | - | - | +| 龙虎榜排名 | `ranking` | 80px | ✓ | 显示"-"表示未上榜 | +| 操作 | - | 120px | - | 查看详情、编辑 | + +**批量操作** +- 导出 Excel (选中项/全部) +- 批量编辑(修改状态等) + +**分页配置** +```typescript +interface PaginationConfig { + pageSize: 10 | 20 | 50 | 100; + current: number; + total: number; +} +``` + +--- + +### 4.4 龙虎榜管理模块 (`/leaderboard`) + +#### 页面布局 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [页面标题] [导出排名数据按钮] │ +├─────────────────────────────────┬───────────────────────────┤ +│ │ │ +│ ┌─────────────────────────┐ │ 榜单设置 │ +│ │ 日榜 [开关] │ │ ┌───────────────────┐ │ +│ │ 排名表格... │ │ │ 虚拟排名设置 │ │ +│ └─────────────────────────┘ │ │ - 启用开关 │ │ +│ │ │ - 虚拟账户数量 │ │ +│ ┌─────────────────────────┐ │ │ - 规则说明 │ │ +│ │ 周榜 [开关] │ │ │ - 实时预览 │ │ +│ │ 榜单未开启/排名表格 │ │ └───────────────────┘ │ +│ └─────────────────────────┘ │ ┌───────────────────┐ │ +│ │ │ 显示设置 │ │ +│ ┌─────────────────────────┐ │ │ - 前端显示数量 │ │ +│ │ 月榜 [开关] │ │ └───────────────────┘ │ +│ │ 榜单未开启/排名表格 │ │ │ +│ └─────────────────────────┘ │ [保存设置按钮] │ +│ │ │ +└─────────────────────────────────┴───────────────────────────┘ +``` + +#### 排名表格 +```typescript +interface RankingItem { + rank: number; // 1, 2, 3 显示奖牌图标 + avatar: string; + nickname: string; + isVirtual: boolean; // 虚拟用户标签 + adoptionCount: number; // X 棵 + teamData: string; // 团队标识 (A队, B队, C队) +} +``` + +#### 榜单设置 +```typescript +interface BoardSettings { + virtualRanking: { + enabled: boolean; + virtualAccountCount: number; // 滑块 0-10 + ruleDescription: string; + }; + displaySettings: { + frontendDisplayCount: 10 | 20 | 50; // 下拉选择 + }; +} +``` + +#### 榜单开关状态 +- **已开启**: 显示排名表格 +- **未开启**: 显示空状态图标 + "榜单未开启" + "待激活" + +--- + +### 4.5 授权管理模块 (`/authorization`) + +#### 页面分区 + +**1. 授权省公司管理** +```typescript +interface ProvinceCompanyFilter { + province: string; + authStatus: 'authorized' | 'pending' | 'all'; + keyword: string; +} + +interface ProvinceCompanyItem { + avatar: string; + nickname: string; + accountId: string; + province: string; + teamAdoptions: number; + authStatus: 'authorized' | 'pending'; + actions: ['authorize' | 'revoke']; +} +``` + +**2. 省公司团队权益考核规则** +```typescript +interface ProvinceAssessmentRules { + firstTriggerThreshold: number; // 首次考核触发门槛 (棵) + stageUnitBenefit: number; // 考核阶段单棵权益 (USDT) + incrementalBenefit: number; // 达标后每新增1棵获得 (USDT) + assessmentCycle: 'monthly' | 'quarterly'; + failureResetEnabled: boolean; // 未达成目标时权益失效并重置 +} +``` + +**3. 授权市公司管理** +- 结构同省公司,增加城市筛选 + +**4. 市公司团队权益考核规则** +- 结构同省公司规则,阈值和权益数值不同 + +**5. 正式省/市公司授权管理** +- 通过全部阶梯性考核后的正式公司列表 + +**6. 授权限制规则** +```typescript +interface AuthorizationLimits { + oneProvinceOneCompany: boolean; // 每省份仅授权1个 + oneCityOneCompany: boolean; // 每城市仅授权1个 + topRankingRequired: number; // 排名前N才具备资格 + specialCityRules: { + beijing: boolean; // 北京特例 + shanghai: boolean; // 上海特例 + }; +} +``` + +**7. 阶梯性考核目标表** +```typescript +interface LadderTarget { + assessmentMonth: number; // 考核月 (1-9) + provinceMonthlyTarget: number; // 省代当月目标 + provinceCumulativeTarget: number; // 省代累计目标 + cityMonthlyTarget: number; // 市代当月目标 + cityCumulativeTarget: number; // 市代累计目标 +} +``` + +--- + +### 4.6 数据统计模块 (`/statistics`) + +#### 页面分区 + +**1. 顶部统计概览** +```typescript +interface StatisticsOverview { + totalAdoptions: number; // 榴莲树认种总量 + todayAdoptions: number; // 今日认种数量 + monthlyAdoptions: number; // 本月认种数量 +} +``` + +**2. 认种数量趋势** +- 时间维度切换: 日 / 周 / 月 / 季度 / 年度 +- 折线图展示 + +**3. 龙虎榜与排名统计** +```typescript +interface LeaderboardStats { + activeTab: 'daily' | 'weekly' | 'monthly'; + rankings: Array<{ + rank: number; + account: string; + adoptionCount: number; + province: string; + city: string; + }>; + topProvince: { + name: string; + completionData: number; + }; + topCity: { + name: string; + completionData: number; + }; +} +``` + +**4. 区域认种数据统计** +- 按省统计 / 按市统计 切换 +- 柱状图 + 详情表格 +```typescript +interface RegionStats { + region: string; + period: string; // 2023-10 + adoptionCount: number; + percentage: number; +} +``` + +**5. 省/市公司运营统计** +```typescript +interface OperationStats { + summary: { + monthlyHashRate: string; // "1,200 TH/s" + cumulativeHashRate: string; // "15,000 TH/s" + monthlyMining: string; // "5.6 BTC" + cumulativeMining: string; // "67.8 BTC" + monthlyCommission: number; // ¥12,345 + cumulativeCommission: number; // ¥150,000 + monthlyAdoptionBonus: number; // ¥8,900 + }; + details: Array<{ + accountType: 'province' | 'city'; + accountName: string; + statMonth: string; + hashRate: string; + miningAmount: string; + commission: number; + }>; +} +``` + +**6. 收益明细与来源** +```typescript +interface RevenueDetail { + timestamp: string; + account: string; + source: 'mining' | 'adoption_completion' | 'share'; + amount: number; // 带+号显示正数 + relatedAddress: string; // bc1...xyz + transactionId: string; // TXN1234567890 +} +``` + +--- + +### 4.7 系统设置模块 (`/settings`) + +#### 设置分区 + +**1. 结算参数设置** +```typescript +interface SettlementConfig { + availableCurrencies: Array<'BNB' | 'OG' | 'USDT' | 'DST'>; + defaultCurrency: string; +} +``` + +**2. 龙虎榜设置** +```typescript +interface LeaderboardConfig { + virtualAccountEnabled: boolean; + virtualAccountCount: number; + boardSwitches: { + daily: boolean; + weekly: boolean; + monthly: boolean; + }; + frontendDisplayCount: string; // "10, 20, 31" +} +``` + +**3. 认种限额设置** +```typescript +interface QuotaConfig { + singleAccountQuota: { + enabled: boolean; + days: number; + maxCount: number; + }; + networkTotalQuota: { + enabled: boolean; + days: number; + maxCount: number; + }; +} +``` + +**4. 考核规则设置** +```typescript +interface AssessmentConfig { + localUserPercentageThreshold: number; // 5% + exemptionEnabled: boolean; // 允许豁免 + ladderTargets: LadderTarget[]; +} +``` + +**5. 前端展示设置** +```typescript +interface DisplayConfig { + allowNonAdopterViewHeatmap: boolean; + heatmapDisplayMode: 'exact_count' | 'level'; // 高/中/低 +} +``` + +**6. 后台账号与安全** +```typescript +interface SecurityConfig { + accounts: Array<{ + username: string; + role: '超级管理员' | '运营人员'; + status: 'active' | 'disabled'; + lastLogin: string; + }>; + sensitiveOperationApprovers: number; // 审批人数 + sensitiveOperations: string[]; // 需审批的操作 + operationLogs: Array<{ + timestamp: string; + operator: string; + type: string; + description: string; + approvalResult: 'approved' | 'pending' | 'rejected'; + }>; +} +``` + +--- + +### 4.8 帮助中心模块 (`/help`) + +#### 页面分区 + +**1. 搜索区** +- 搜索文档或问题的输入框 + +**2. 推荐文档卡片** +```typescript +interface RecommendedDoc { + title: string; + description: string; + helpfulCount: number; + notHelpfulCount: number; +} +``` +固定展示3个推荐文档 + +**3. 文档列表** +```typescript +interface DocumentItem { + title: string; + category: '系统设置' | '账号安全' | '数据统计' | '授权管理'; + lastUpdated: string; + rating: { + helpful: boolean | null; + }; +} +``` + +**4. 常见问题 (FAQ)** +```typescript +interface FAQItem { + question: string; + answer: string; + expanded: boolean; +} +``` + +**5. 联系支持** +```typescript +interface SupportInfo { + phone: string; // "400-123-4567" + workingHours: string; // "工作日 9:00 - 18:00" + email: string; // "support@durian-adopt.com" + onlineChat: boolean; // 在线客服入口 +} +``` + +--- + +## 5. UI/UX 设计规范 + +### 5.1 色彩系统 + +```scss +// 主色调 +$primary-color: #1565C0; // 主蓝色 - 按钮、链接、激活状态 +$primary-light: #1E88E5; // 浅蓝色 - Hover 状态 +$primary-dark: #0D47A1; // 深蓝色 - 点击状态 + +// 辅助色 +$success-color: #4CAF50; // 成功绿 +$warning-color: #F5A623; // 警告黄/金色 +$error-color: #E53935; // 错误红 +$info-color: #2196F3; // 信息蓝 + +// 中性色 +$text-primary: #212121; // 主要文字 +$text-secondary: #757575; // 次要文字 +$text-disabled: #BDBDBD; // 禁用文字 +$border-color: #E0E0E0; // 边框色 +$background-color: #F5F5F5; // 页面背景 +$card-background: #FFFFFF; // 卡片背景 +$sidebar-background: #1565C0; // 侧边栏背景 + +// 语义色 +$increase-color: #4CAF50; // 增长 (+5.6%) +$decrease-color: #E53935; // 下降 +``` + +### 5.2 字体系统 + +```scss +// 字体族 +$font-family-base: 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif; +$font-family-number: 'DIN Alternate', 'Roboto', $font-family-base; + +// 字号 +$font-size-xs: 12px; // 辅助文字、标签 +$font-size-sm: 13px; // 表格内容 +$font-size-base: 14px; // 正文 +$font-size-md: 16px; // 小标题 +$font-size-lg: 18px; // 二级标题 +$font-size-xl: 20px; // 页面标题 +$font-size-xxl: 24px; // 大数字 +$font-size-display: 32px; // 统计数字 + +// 行高 +$line-height-tight: 1.25; +$line-height-base: 1.5; +$line-height-loose: 1.75; + +// 字重 +$font-weight-regular: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; +``` + +### 5.3 间距系统 + +```scss +// 基础间距 (4px 为基础单位) +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 12px; +$spacing-base: 16px; +$spacing-lg: 20px; +$spacing-xl: 24px; +$spacing-xxl: 32px; +$spacing-xxxl: 48px; + +// 组件内间距 +$padding-card: 24px; +$padding-modal: 24px; +$padding-input: 12px 16px; +$padding-button: 10px 24px; + +// 页面边距 +$page-padding: 24px; +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +``` + +### 5.4 圆角系统 + +```scss +$border-radius-sm: 4px; // 小按钮、标签 +$border-radius-base: 8px; // 输入框、卡片 +$border-radius-lg: 12px; // 大卡片、弹窗 +$border-radius-xl: 16px; // 特殊组件 +$border-radius-round: 50%; // 圆形头像 +$border-radius-pill: 999px; // 药丸形状 +``` + +### 5.5 阴影系统 + +```scss +$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); +$shadow-base: 0 2px 8px rgba(0, 0, 0, 0.08); +$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); +$shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.15); +``` + +### 5.6 动画系统 + +```scss +// 时长 +$duration-fast: 150ms; +$duration-base: 250ms; +$duration-slow: 350ms; + +// 缓动函数 +$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); +$ease-out: cubic-bezier(0, 0, 0.2, 1); +$ease-in: cubic-bezier(0.4, 0, 1, 1); + +// 常用动画 +@mixin transition-base { + transition: all $duration-base $ease-in-out; +} + +@mixin hover-lift { + transition: transform $duration-fast $ease-out, box-shadow $duration-fast $ease-out; + &:hover { + transform: translateY(-2px); + box-shadow: $shadow-md; + } +} +``` + +--- + +## 6. 组件设计指南 + +### 6.1 基础组件清单 + +#### Button 按钮 +```typescript +interface ButtonProps { + variant: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'; + size: 'sm' | 'md' | 'lg'; + icon?: ReactNode; + iconPosition?: 'left' | 'right'; + loading?: boolean; + disabled?: boolean; + fullWidth?: boolean; + children: ReactNode; + onClick?: () => void; +} +``` + +**样式规范**: +- Primary: 蓝色填充背景 `#1565C0`,白色文字 +- Secondary: 白色背景,蓝色边框和文字 +- Outline: 透明背景,灰色边框 +- Ghost: 无边框,悬停显示背景 +- Danger: 红色填充背景 + +#### Input 输入框 +```typescript +interface InputProps { + type: 'text' | 'password' | 'email' | 'number'; + size: 'sm' | 'md' | 'lg'; + placeholder?: string; + prefix?: ReactNode; // 前置图标 + suffix?: ReactNode; // 后置图标/清除按钮 + error?: string; + disabled?: boolean; + value: string; + onChange: (value: string) => void; +} +``` + +#### Select 下拉选择 +```typescript +interface SelectProps { + options: Array<{ label: string; value: T }>; + value: T; + onChange: (value: T) => void; + placeholder?: string; + multiple?: boolean; + searchable?: boolean; + disabled?: boolean; +} +``` + +#### Table 表格 +```typescript +interface TableProps { + columns: Array<{ + key: string; + title: string; + width?: number; + sortable?: boolean; + render?: (value: any, record: T) => ReactNode; + }>; + data: T[]; + loading?: boolean; + pagination?: PaginationConfig; + rowSelection?: { + selectedRowKeys: string[]; + onChange: (keys: string[]) => void; + }; + onRowClick?: (record: T) => void; +} +``` + +#### Modal 弹窗 +```typescript +interface ModalProps { + visible: boolean; + title: string; + width?: number; + onClose: () => void; + onConfirm?: () => void; + confirmText?: string; + cancelText?: string; + loading?: boolean; + children: ReactNode; +} +``` + +#### Card 卡片 +```typescript +interface CardProps { + title?: string; + extra?: ReactNode; // 右上角操作区 + padding?: boolean; + shadow?: boolean; + children: ReactNode; +} +``` + +### 6.2 布局组件 + +#### Sidebar 侧边栏 +```typescript +interface SidebarProps { + collapsed: boolean; + onCollapse: (collapsed: boolean) => void; + activeKey: string; + menuItems: Array<{ + key: string; + icon: ReactNode; + label: string; + path: string; + badge?: number; + }>; +} +``` + +**菜单项结构**: +```typescript +const menuItems = [ + { key: 'dashboard', icon: , label: '仪表板', path: '/dashboard' }, + { key: 'users', icon: , label: '用户管理', path: '/users' }, + { key: 'leaderboard', icon: , label: '龙虎榜', path: '/leaderboard' }, + { key: 'authorization', icon: , label: '授权管理', path: '/authorization' }, + { key: 'statistics', icon: , label: '数据统计', path: '/statistics' }, + { key: 'settings', icon: , label: '系统设置', path: '/settings' }, + // 底部固定 + { key: 'help', icon: , label: '帮助中心', path: '/help', position: 'bottom' }, + { key: 'logout', icon: , label: '退出登录', path: '/logout', position: 'bottom' }, +]; +``` + +#### Header 顶部栏 +```typescript +interface HeaderProps { + title: string; + breadcrumb: Array<{ label: string; path?: string }>; + actions?: ReactNode; + user: { + name: string; + role: string; + avatar: string; + }; + notifications?: number; +} +``` + +### 6.3 图表组件 + +#### StatCard 统计卡片 +```typescript +interface StatCardProps { + title: string; + value: number | string; + suffix?: string; // "棵", "人" + change?: { + value: number; + trend: 'up' | 'down'; + }; + icon?: ReactNode; + color?: string; +} +``` + +**视觉规范**: +- 卡片白色背景,8px 圆角 +- 数字使用 `$font-family-number`,32px 字号 +- 增长指标使用绿色,下降使用红色 +- 带 `+` 或 `-` 前缀 + +#### LineChart 折线图 +```typescript +interface LineChartProps { + data: Array<{ x: string; y: number }>; + xAxisKey: string; + yAxisKey: string; + color?: string; + gradient?: boolean; // 面积渐变填充 + height?: number; +} +``` + +#### BarChart 柱状图 +```typescript +interface BarChartProps { + data: Array<{ category: string; value: number }>; + colors?: string[]; + horizontal?: boolean; + showLabels?: boolean; + height?: number; +} +``` + +#### DonutChart 环形图 +```typescript +interface DonutChartProps { + data: Array<{ name: string; value: number; color: string }>; + innerRadius?: number; + outerRadius?: number; + centerContent?: ReactNode; // 中心显示内容 +} +``` + +--- + +## 7. 状态管理方案 + +### 7.1 Redux Toolkit (全局状态) + +用于管理需要跨页面持久化的状态: + +```typescript +// store/redux/slices/authSlice.ts +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + permissions: string[]; +} + +// store/redux/slices/settingsSlice.ts +interface SettingsState { + sidebarCollapsed: boolean; + theme: 'light' | 'dark'; + language: 'zh-CN' | 'en-US'; +} + +// store/redux/slices/notificationSlice.ts +interface NotificationState { + unreadCount: number; + notifications: Notification[]; +} +``` + +### 7.2 Zustand (局部状态) + +用于管理页面级别的临时状态: + +```typescript +// store/zustand/useUserFiltersStore.ts +interface UserFiltersStore { + keyword: string; + province: string; + city: string; + status: string; + setKeyword: (keyword: string) => void; + setFilters: (filters: Partial) => void; + resetFilters: () => void; +} + +// store/zustand/useLeaderboardStore.ts +interface LeaderboardStore { + activeBoard: 'daily' | 'weekly' | 'monthly'; + virtualSettings: VirtualSettings; + setActiveBoard: (board: string) => void; + updateVirtualSettings: (settings: Partial) => void; +} + +// store/zustand/useModalStore.ts +interface ModalStore { + activeModal: string | null; + modalData: any; + openModal: (name: string, data?: any) => void; + closeModal: () => void; +} +``` + +### 7.3 React Query (服务端状态) + +用于管理 API 数据缓存: + +```typescript +// hooks/useUsers.ts +export const useUsers = (filters: UserFilters) => { + return useQuery({ + queryKey: ['users', filters], + queryFn: () => userService.getUsers(filters), + staleTime: 5 * 60 * 1000, // 5分钟缓存 + }); +}; + +// hooks/useStatistics.ts +export const useStatistics = (period: string) => { + return useQuery({ + queryKey: ['statistics', period], + queryFn: () => statisticsService.getOverview(period), + refetchInterval: 30 * 1000, // 30秒自动刷新 + }); +}; +``` + +--- + +## 8. API 集成规范 + +### 8.1 API 客户端配置 + +```typescript +// infrastructure/api/client.ts +import axios from 'axios'; + +const apiClient = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 请求拦截器 +apiClient.interceptors.request.use((config) => { + const token = store.getState().auth.token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 响应拦截器 +apiClient.interceptors.response.use( + (response) => response.data, + (error) => { + if (error.response?.status === 401) { + store.dispatch(logout()); + router.push('/login'); + } + return Promise.reject(error); + } +); +``` + +### 8.2 API 端点定义 + +```typescript +// infrastructure/api/endpoints.ts +export const API_ENDPOINTS = { + // 认证 + AUTH: { + LOGIN: '/auth/login', + LOGOUT: '/auth/logout', + REFRESH: '/auth/refresh', + FORGOT_PASSWORD: '/auth/forgot-password', + }, + + // 用户管理 + USERS: { + LIST: '/users', + DETAIL: (id: string) => `/users/${id}`, + UPDATE: (id: string) => `/users/${id}`, + EXPORT: '/users/export', + }, + + // 龙虎榜 + LEADERBOARD: { + RANKINGS: '/leaderboard/rankings', + SETTINGS: '/leaderboard/settings', + EXPORT: '/leaderboard/export', + }, + + // 授权管理 + AUTHORIZATION: { + PROVINCE_COMPANIES: '/authorization/province-companies', + CITY_COMPANIES: '/authorization/city-companies', + ASSESSMENT_RULES: '/authorization/assessment-rules', + LADDER_TARGETS: '/authorization/ladder-targets', + }, + + // 数据统计 + STATISTICS: { + OVERVIEW: '/statistics/overview', + TREND: '/statistics/trend', + REGION: '/statistics/region', + OPERATIONS: '/statistics/operations', + REVENUE: '/statistics/revenue', + }, + + // 系统设置 + SETTINGS: { + ALL: '/settings', + SETTLEMENT: '/settings/settlement', + LEADERBOARD: '/settings/leaderboard', + QUOTA: '/settings/quota', + ASSESSMENT: '/settings/assessment', + DISPLAY: '/settings/display', + SECURITY: '/settings/security', + }, + + // 帮助中心 + HELP: { + DOCUMENTS: '/help/documents', + FAQ: '/help/faq', + SEARCH: '/help/search', + }, +}; +``` + +### 8.3 通用响应格式 + +```typescript +// types/api.types.ts +interface ApiResponse { + code: number; + message: string; + data: T; +} + +interface PaginatedResponse { + code: number; + message: string; + data: { + list: T[]; + pagination: { + current: number; + pageSize: number; + total: number; + }; + }; +} +``` + +--- + +## 9. 开发规范与约定 + +### 9.1 命名规范 + +```typescript +// 文件命名 +components/Button/Button.tsx // PascalCase 组件 +components/Button/Button.module.scss // 样式模块 +hooks/useAuth.ts // camelCase hooks +utils/formatters.ts // camelCase 工具 +types/user.types.ts // kebab-case 类型文件 + +// 变量命名 +const userName = 'admin'; // camelCase 变量 +const MAX_RETRY_COUNT = 3; // UPPER_SNAKE_CASE 常量 +const UserRole = { ADMIN: 'admin' }; // PascalCase 枚举 + +// 组件命名 +const UserListPage = () => {}; // PascalCase 组件 +const useUserData = () => {}; // use 前缀 hooks + +// CSS 类命名 (BEM 风格) +.button {} +.button--primary {} +.button--disabled {} +.button__icon {} +``` + +### 9.2 代码组织 + +```typescript +// 组件文件结构 +// components/Button/Button.tsx +import { FC } from 'react'; +import styles from './Button.module.scss'; +import type { ButtonProps } from './Button.types'; + +// 1. 类型定义 (或单独文件) +// 2. 常量定义 +// 3. 辅助函数 +// 4. 组件定义 +// 5. 导出 + +export const Button: FC = ({ + variant = 'primary', + size = 'md', + children, + ...props +}) => { + return ( + + ); +}; +``` + +### 9.3 Git 提交规范 + +```bash +# 格式: (): + +feat(users): 添加用户列表分页功能 +fix(auth): 修复登录token失效问题 +style(button): 调整按钮hover状态样式 +refactor(api): 重构API请求拦截器 +docs(readme): 更新项目说明文档 +test(users): 添加用户服务单元测试 +chore(deps): 更新依赖版本 +``` + +### 9.4 注释规范 + +```typescript +/** + * 用户服务类 + * 处理所有用户相关的业务逻辑 + */ +class UserService { + /** + * 获取用户列表 + * @param filters - 筛选条件 + * @param pagination - 分页参数 + * @returns 用户列表响应 + */ + async getUsers( + filters: UserFilters, + pagination: PaginationParams + ): Promise> { + // TODO: 添加缓存优化 + // FIXME: 处理边界情况 + return apiClient.get(API_ENDPOINTS.USERS.LIST, { + params: { ...filters, ...pagination }, + }); + } +} +``` + +--- + +## 10. 开发优先级与里程碑 + +### Phase 1: 基础框架搭建 (Week 1-2) + +**目标**: 完成项目基础架构和核心组件库 + +``` +├── 项目初始化 +│ ├── Next.js 15 项目创建 +│ ├── TypeScript 配置 +│ ├── ESLint + Prettier 配置 +│ ├── 目录结构创建 +│ └── 环境变量配置 +│ +├── 样式系统 +│ ├── CSS 变量定义 +│ ├── SCSS Mixins +│ ├── 全局样式重置 +│ └── 字体引入 +│ +├── 基础组件 (10个) +│ ├── Button +│ ├── Input +│ ├── Select +│ ├── Table (基础版) +│ ├── Modal +│ ├── Card +│ ├── Badge +│ ├── Avatar +│ ├── Loading +│ └── Toast +│ +└── 布局组件 + ├── Sidebar + ├── Header + └── PageContainer +``` + +### Phase 2: 认证与仪表板 (Week 3) + +**目标**: 完成登录流程和仪表板页面 + +``` +├── 登录模块 +│ ├── 登录页面 UI +│ ├── 表单验证 +│ ├── 登录 API 集成 +│ ├── Token 管理 +│ └── 路由守卫 +│ +├── 仪表板模块 +│ ├── 统计卡片组件 +│ ├── 折线图组件 +│ ├── 柱状图组件 +│ ├── 环形图组件 +│ ├── 最近活动列表 +│ └── 数据获取与展示 +│ +└── 状态管理 + ├── Redux Store 配置 + ├── Auth Slice + └── React Query 配置 +``` + +### Phase 3: 用户管理 (Week 4) + +**目标**: 完成用户管理全部功能 + +``` +├── 用户列表 +│ ├── 表格组件完善 +│ ├── 搜索功能 +│ ├── 高级筛选 +│ ├── 分页功能 +│ └── 排序功能 +│ +├── 用户操作 +│ ├── 查看详情弹窗 +│ ├── 编辑用户 +│ ├── 批量选择 +│ └── 导出 Excel +│ +└── Zustand 集成 + └── 用户筛选状态管理 +``` + +### Phase 4: 龙虎榜与授权 (Week 5-6) + +**目标**: 完成龙虎榜和授权管理模块 + +``` +├── 龙虎榜模块 +│ ├── 日/周/月榜切换 +│ ├── 排名表格 +│ ├── 榜单开关 +│ ├── 虚拟排名设置 +│ ├── 显示设置 +│ └── 导出功能 +│ +└── 授权管理模块 + ├── 省公司管理 + ├── 市公司管理 + ├── 考核规则配置 + ├── 限制规则设置 + └── 阶梯目标表格 +``` + +### Phase 5: 数据统计 (Week 7) + +**目标**: 完成数据统计全部图表和报表 + +``` +├── 统计概览 +│ ├── 顶部统计卡片 +│ └── 趋势图表 +│ +├── 详细统计 +│ ├── 龙虎榜统计 +│ ├── 区域数据统计 +│ ├── 公司运营统计 +│ └── 收益明细 +│ +└── 导出功能 + ├── 运营报表导出 + └── 明细数据导出 +``` + +### Phase 6: 系统设置与帮助 (Week 8) + +**目标**: 完成系统设置和帮助中心 + +``` +├── 系统设置 +│ ├── 结算参数 +│ ├── 龙虎榜设置 +│ ├── 认种限额 +│ ├── 考核规则 +│ ├── 前端展示 +│ └── 账号与安全 +│ +├── 帮助中心 +│ ├── 搜索功能 +│ ├── 推荐文档 +│ ├── 文档列表 +│ ├── FAQ +│ └── 联系支持 +│ +└── 全局功能 + ├── 通知系统 + └── 操作日志 +``` + +### Phase 7: 测试与优化 (Week 9-10) + +**目标**: 完成测试、性能优化和上线准备 + +``` +├── 测试 +│ ├── 单元测试 +│ ├── 集成测试 +│ └── E2E 测试 +│ +├── 性能优化 +│ ├── 代码分割 +│ ├── 图片优化 +│ ├── 缓存策略 +│ └── Bundle 分析 +│ +└── 部署准备 + ├── CI/CD 配置 + ├── 环境配置 + └── 监控接入 +``` + +--- + +## 附录 + +### A. 图标资源 + +建议使用以下图标来源: +- 侧边栏菜单图标: 自定义 SVG 或 Lucide Icons +- 操作图标: 保持与 Figma 设计一致 + +### B. 第三方库版本 + +```json +{ + "dependencies": { + "react": "^18.2.0", + "next": "^15.0.0", + "typescript": "^5.0.0", + "@reduxjs/toolkit": "^2.0.0", + "zustand": "^4.4.0", + "@tanstack/react-query": "^5.0.0", + "axios": "^1.6.0", + "recharts": "^2.10.0", + "xlsx": "^0.18.5", + "dayjs": "^1.11.0", + "clsx": "^2.0.0" + } +} +``` + +### C. 环境变量 + +```bash +# .env.local +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api +NEXT_PUBLIC_APP_NAME=榴莲认种管理后台 +NEXT_PUBLIC_VERSION=1.0.0 +``` + +--- + +**文档版本**: 1.0.0 +**最后更新**: 2024年 +**维护者**: Claude Code