From cc33d01be32146d31f0613fad8e5f1db2bc2b8b4 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 1 Dec 2025 02:08:24 -0800 Subject: [PATCH] . --- .../leaderboard-service/DEVELOPMENT_GUIDE.md | 2244 +++++++++++++++++ 1 file changed, 2244 insertions(+) create mode 100644 backend/services/leaderboard-service/DEVELOPMENT_GUIDE.md diff --git a/backend/services/leaderboard-service/DEVELOPMENT_GUIDE.md b/backend/services/leaderboard-service/DEVELOPMENT_GUIDE.md new file mode 100644 index 00000000..873d5bed --- /dev/null +++ b/backend/services/leaderboard-service/DEVELOPMENT_GUIDE.md @@ -0,0 +1,2244 @@ +# Leaderboard Service 开发指导 + +## 项目概述 + +Leaderboard Service 是 RWA 榴莲女皇平台的龙虎榜微服务,负责管理用户排名计算、虚拟账户管理、榜单数据聚合与缓存、日/周/月榜单管理等功能。 + +### 核心职责 ✅ +- 龙虎榜排名计算(日榜、周榜、月榜) +- 虚拟排名功能(系统生成虚拟账户参与排名) +- 榜单开关管理(日榜/周榜/月榜独立开关) +- 榜单显示数量设置 +- 虚拟账户管理(系统省公司、系统市公司、总部社区) +- 榜单数据缓存与定时刷新 + +### 不负责 ❌ +- 实际业务数据存储(从其他上下文读取) +- 权限验证(Authorization Context) +- 用户基本信息管理(Identity Context) +- 团队统计计算(Referral Context) + +--- + +## 核心业务规则 + +### 1. 龙虎榜分值计算 + +``` +龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量 + +目的: +- 鼓励均衡发展团队,而不是只依赖单个大团队 +- 防止"烧伤"现象(单腿发展) + +示例: +用户A的团队数据: +- 直推B的团队认种: 100棵 +- 直推C的团队认种: 80棵 +- 直推D的团队认种: 50棵 +- 团队总认种: 230棵 +- 最大单个直推团队: 100棵 (B) +- 龙虎榜分值: 230 - 100 = 130 +``` + +### 2. 榜单周期 + +| 榜单类型 | 统计周期 | 重置时间 | +|---------|---------|---------| +| 日榜 | 当日 00:00 - 23:59 | 每日 00:00 重置 | +| 周榜 | 周一 00:00 - 周日 23:59 | 每周一 00:00 重置 | +| 月榜 | 每月1日 00:00 - 月末 23:59 | 每月1日 00:00 重置 | + +### 3. 虚拟排名功能 + +``` +虚拟账户数量设置: +- 设置为 30:真实排名第1的用户显示在第31名 +- 设置为 29:真实排名第1的用户显示在第1名(虚拟账户占29位) +- 设置为 0:关闭虚拟排名,完全显示真实排名 + +虚拟账户特点: +- 系统自动生成 +- 显示随机用户名和头像 +- 分值在合理范围内随机生成 +- 不参与实际奖励分配 +``` + +### 4. 榜单开关功能 + +| 开关 | 状态 | 前端显示 | +|------|------|---------| +| 日榜开关 | 关闭 | "日榜待开启" | +| 周榜开关 | 关闭 | "周榜待开启" | +| 月榜开关 | 关闭 | "月榜待开启" | + +### 5. 显示数量设置 + +- 前端排名数量可配置(如:10、20、30、50、100) +- 默认显示前30名 +- 超出设置数量的排名不在前端展示 + +--- + +## 技术栈 + +| 组件 | 技术选型 | +|------|----------| +| **框架** | NestJS 10.x | +| **数据库** | PostgreSQL + Prisma ORM | +| **架构** | DDD + Hexagonal Architecture (六边形架构) | +| **语言** | TypeScript 5.x | +| **缓存** | Redis (ioredis) | +| **定时任务** | @nestjs/schedule | +| **消息队列** | Kafka (kafkajs) | +| **API文档** | Swagger (@nestjs/swagger) | + +--- + +## 架构设计 + +``` +leaderboard-service/ +├── prisma/ +│ ├── schema.prisma # 数据库模型定义 +│ └── migrations/ # 数据库迁移文件 +│ +├── src/ +│ ├── api/ # 🔵 Presentation Layer (表现层) +│ │ ├── controllers/ +│ │ │ ├── health.controller.ts +│ │ │ ├── leaderboard.controller.ts +│ │ │ ├── leaderboard-config.controller.ts +│ │ │ └── virtual-account.controller.ts +│ │ ├── dto/ +│ │ │ ├── request/ +│ │ │ │ ├── update-leaderboard-config.dto.ts +│ │ │ │ └── query-leaderboard.dto.ts +│ │ │ └── response/ +│ │ │ ├── leaderboard-ranking.dto.ts +│ │ │ ├── leaderboard-config.dto.ts +│ │ │ └── virtual-account.dto.ts +│ │ └── api.module.ts +│ │ +│ ├── application/ # 🟢 Application Layer (应用层) +│ │ ├── commands/ +│ │ │ ├── refresh-leaderboard/ +│ │ │ │ ├── refresh-leaderboard.command.ts +│ │ │ │ └── refresh-leaderboard.handler.ts +│ │ │ ├── update-leaderboard-config/ +│ │ │ │ ├── update-leaderboard-config.command.ts +│ │ │ │ └── update-leaderboard-config.handler.ts +│ │ │ ├── generate-virtual-accounts/ +│ │ │ │ ├── generate-virtual-accounts.command.ts +│ │ │ │ └── generate-virtual-accounts.handler.ts +│ │ │ └── index.ts +│ │ ├── queries/ +│ │ │ ├── get-leaderboard/ +│ │ │ │ ├── get-leaderboard.query.ts +│ │ │ │ └── get-leaderboard.handler.ts +│ │ │ ├── get-my-ranking/ +│ │ │ │ ├── get-my-ranking.query.ts +│ │ │ │ └── get-my-ranking.handler.ts +│ │ │ ├── get-leaderboard-config/ +│ │ │ │ ├── get-leaderboard-config.query.ts +│ │ │ │ └── get-leaderboard-config.handler.ts +│ │ │ └── index.ts +│ │ ├── services/ +│ │ │ └── leaderboard-application.service.ts +│ │ ├── schedulers/ +│ │ │ └── leaderboard-refresh.scheduler.ts +│ │ └── application.module.ts +│ │ +│ ├── domain/ # 🟡 Domain Layer (领域层) +│ │ ├── aggregates/ +│ │ │ ├── leaderboard-ranking/ +│ │ │ │ ├── leaderboard-ranking.aggregate.ts +│ │ │ │ ├── leaderboard-ranking.spec.ts +│ │ │ │ └── index.ts +│ │ │ └── leaderboard-config/ +│ │ │ ├── leaderboard-config.aggregate.ts +│ │ │ ├── leaderboard-config.spec.ts +│ │ │ └── index.ts +│ │ ├── entities/ +│ │ │ ├── virtual-account.entity.ts +│ │ │ ├── virtual-ranking-entry.entity.ts +│ │ │ └── index.ts +│ │ ├── value-objects/ +│ │ │ ├── leaderboard-type.enum.ts +│ │ │ ├── leaderboard-period.enum.ts +│ │ │ ├── rank-position.vo.ts +│ │ │ ├── ranking-score.vo.ts +│ │ │ ├── user-snapshot.vo.ts +│ │ │ ├── virtual-account-type.enum.ts +│ │ │ └── index.ts +│ │ ├── events/ +│ │ │ ├── domain-event.base.ts +│ │ │ ├── leaderboard-refreshed.event.ts +│ │ │ ├── ranking-changed.event.ts +│ │ │ ├── config-updated.event.ts +│ │ │ └── index.ts +│ │ ├── repositories/ +│ │ │ ├── leaderboard-ranking.repository.interface.ts +│ │ │ ├── leaderboard-config.repository.interface.ts +│ │ │ ├── virtual-account.repository.interface.ts +│ │ │ └── index.ts +│ │ ├── services/ +│ │ │ ├── leaderboard-calculation.service.ts +│ │ │ ├── virtual-ranking-generator.service.ts +│ │ │ ├── ranking-merger.service.ts +│ │ │ └── index.ts +│ │ └── domain.module.ts +│ │ +│ ├── infrastructure/ # 🔴 Infrastructure Layer (基础设施层) +│ │ ├── persistence/ +│ │ │ ├── prisma/ +│ │ │ │ └── prisma.service.ts +│ │ │ ├── mappers/ +│ │ │ │ ├── leaderboard-ranking.mapper.ts +│ │ │ │ ├── leaderboard-config.mapper.ts +│ │ │ │ └── virtual-account.mapper.ts +│ │ │ └── repositories/ +│ │ │ ├── leaderboard-ranking.repository.impl.ts +│ │ │ ├── leaderboard-config.repository.impl.ts +│ │ │ └── virtual-account.repository.impl.ts +│ │ ├── external/ +│ │ │ ├── referral-service/ +│ │ │ │ └── referral-service.client.ts +│ │ │ └── identity-service/ +│ │ │ └── identity-service.client.ts +│ │ ├── kafka/ +│ │ │ ├── event-consumer.controller.ts +│ │ │ ├── event-publisher.service.ts +│ │ │ └── kafka.module.ts +│ │ ├── redis/ +│ │ │ ├── redis.service.ts +│ │ │ ├── leaderboard-cache.service.ts +│ │ │ └── redis.module.ts +│ │ └── infrastructure.module.ts +│ │ +│ ├── shared/ # 共享模块 +│ │ ├── decorators/ +│ │ │ ├── current-user.decorator.ts +│ │ │ ├── public.decorator.ts +│ │ │ └── index.ts +│ │ ├── exceptions/ +│ │ │ ├── domain.exception.ts +│ │ │ ├── application.exception.ts +│ │ │ └── index.ts +│ │ ├── filters/ +│ │ │ ├── global-exception.filter.ts +│ │ │ └── domain-exception.filter.ts +│ │ ├── guards/ +│ │ │ ├── jwt-auth.guard.ts +│ │ │ └── admin.guard.ts +│ │ ├── interceptors/ +│ │ │ └── transform.interceptor.ts +│ │ └── strategies/ +│ │ └── jwt.strategy.ts +│ │ +│ ├── config/ +│ │ ├── app.config.ts +│ │ ├── database.config.ts +│ │ ├── jwt.config.ts +│ │ ├── redis.config.ts +│ │ ├── kafka.config.ts +│ │ └── index.ts +│ │ +│ ├── app.module.ts +│ └── main.ts +│ +├── test/ +├── .env.example +├── .env.development +├── package.json +├── tsconfig.json +└── Dockerfile +``` + +--- + +## 第一阶段:项目初始化 + +### 1.1 创建 NestJS 项目 + +```bash +cd backend/services/leaderboard-service +npx @nestjs/cli new . --skip-git --package-manager npm +``` + +### 1.2 安装依赖 + +```bash +# 核心依赖 +npm install @nestjs/config @nestjs/swagger @nestjs/jwt @nestjs/passport @nestjs/microservices @nestjs/schedule +npm install @prisma/client class-validator class-transformer uuid ioredis kafkajs +npm install passport passport-jwt + +# 开发依赖 +npm install -D prisma @types/uuid @types/passport-jwt +npm install -D @nestjs/testing jest ts-jest @types/jest supertest @types/supertest +``` + +### 1.3 环境变量配置 + +创建 `.env.development`: +```env +# 应用配置 +NODE_ENV=development +PORT=3007 +APP_NAME=leaderboard-service + +# 数据库 +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_leaderboard?schema=public" + +# JWT (与 identity-service 共享密钥) +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_ACCESS_EXPIRES_IN=2h + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Kafka +KAFKA_BROKERS=localhost:9092 +KAFKA_GROUP_ID=leaderboard-service-group +KAFKA_CLIENT_ID=leaderboard-service + +# 外部服务 +IDENTITY_SERVICE_URL=http://localhost:3001 +REFERRAL_SERVICE_URL=http://localhost:3004 + +# 榜单刷新间隔(毫秒) +LEADERBOARD_REFRESH_INTERVAL=300000 + +# 榜单缓存过期时间(秒) +LEADERBOARD_CACHE_TTL=300 +``` + +--- + +## 第二阶段:数据库设计 (Prisma Schema) + +### 2.1 创建 prisma/schema.prisma + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// 龙虎榜排名表 (聚合根1) +// 存储各周期榜单的实际排名数据 +// ============================================ +model LeaderboardRanking { + id BigInt @id @default(autoincrement()) @map("ranking_id") + + // === 榜单信息 === + leaderboardType String @map("leaderboard_type") @db.VarChar(30) // DAILY/WEEKLY/MONTHLY + periodKey String @map("period_key") @db.VarChar(20) // 2024-01-15 / 2024-W03 / 2024-01 + + // === 用户信息 === + userId BigInt @map("user_id") + isVirtual Boolean @default(false) @map("is_virtual") // 是否虚拟账户 + + // === 排名信息 === + rankPosition Int @map("rank_position") // 实际排名 + displayPosition Int @map("display_position") // 显示排名(含虚拟) + previousRank Int? @map("previous_rank") // 上次排名 + + // === 分值信息 === + totalTeamPlanting Int @default(0) @map("total_team_planting") // 团队总认种 + maxDirectTeamPlanting Int @default(0) @map("max_direct_team_planting") // 最大直推团队认种 + effectiveScore Int @default(0) @map("effective_score") // 有效分值 + + // === 用户快照 === + userSnapshot Json @map("user_snapshot") // { nickname, avatar, accountNo } + + // === 时间戳 === + periodStartAt DateTime @map("period_start_at") + periodEndAt DateTime @map("period_end_at") + calculatedAt DateTime @default(now()) @map("calculated_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([leaderboardType, periodKey, userId], name: "uk_type_period_user") + @@map("leaderboard_rankings") + @@index([leaderboardType, periodKey, displayPosition], name: "idx_display_rank") + @@index([leaderboardType, periodKey, effectiveScore(sort: Desc)], name: "idx_score") + @@index([userId], name: "idx_ranking_user") + @@index([periodKey], name: "idx_period") + @@index([isVirtual], name: "idx_virtual") +} + +// ============================================ +// 龙虎榜配置表 (聚合根2) +// 管理榜单开关、虚拟数量、显示设置 +// ============================================ +model LeaderboardConfig { + id BigInt @id @default(autoincrement()) @map("config_id") + configKey String @unique @map("config_key") @db.VarChar(50) // GLOBAL / DAILY / WEEKLY / MONTHLY + + // === 榜单开关 === + dailyEnabled Boolean @default(true) @map("daily_enabled") + weeklyEnabled Boolean @default(true) @map("weekly_enabled") + monthlyEnabled Boolean @default(true) @map("monthly_enabled") + + // === 虚拟排名设置 === + virtualRankingEnabled Boolean @default(false) @map("virtual_ranking_enabled") + virtualAccountCount Int @default(0) @map("virtual_account_count") // 虚拟账户数量 + + // === 显示设置 === + displayLimit Int @default(30) @map("display_limit") // 前端显示数量 + + // === 刷新设置 === + refreshIntervalMinutes Int @default(5) @map("refresh_interval_minutes") + + // === 时间戳 === + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("leaderboard_configs") +} + +// ============================================ +// 虚拟账户表 +// 存储系统生成的虚拟排名账户 +// ============================================ +model VirtualAccount { + id BigInt @id @default(autoincrement()) @map("virtual_account_id") + + // === 账户信息 === + accountType String @map("account_type") @db.VarChar(30) // RANKING_VIRTUAL / SYSTEM_PROVINCE / SYSTEM_CITY / HEADQUARTERS + displayName String @map("display_name") @db.VarChar(100) + avatar String? @map("avatar") @db.VarChar(255) + + // === 区域信息(省市公司用)=== + provinceCode String? @map("province_code") @db.VarChar(10) + cityCode String? @map("city_code") @db.VarChar(10) + + // === 虚拟分值范围(排名虚拟账户用)=== + minScore Int? @map("min_score") + maxScore Int? @map("max_score") + currentScore Int @default(0) @map("current_score") + + // === 账户余额(省市公司用)=== + usdtBalance Decimal @default(0) @map("usdt_balance") @db.Decimal(20, 8) + hashpowerBalance Decimal @default(0) @map("hashpower_balance") @db.Decimal(20, 8) + + // === 状态 === + isActive Boolean @default(true) @map("is_active") + + // === 时间戳 === + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("virtual_accounts") + @@index([accountType], name: "idx_va_type") + @@index([provinceCode], name: "idx_va_province") + @@index([cityCode], name: "idx_va_city") + @@index([isActive], name: "idx_va_active") +} + +// ============================================ +// 虚拟排名条目表 +// 每个周期的虚拟排名数据 +// ============================================ +model VirtualRankingEntry { + id BigInt @id @default(autoincrement()) @map("entry_id") + + // === 关联虚拟账户 === + virtualAccountId BigInt @map("virtual_account_id") + + // === 榜单信息 === + leaderboardType String @map("leaderboard_type") @db.VarChar(30) + periodKey String @map("period_key") @db.VarChar(20) + + // === 排名信息 === + displayPosition Int @map("display_position") // 占据的显示位置 + generatedScore Int @map("generated_score") // 生成的分值 + + // === 显示信息 === + displayName String @map("display_name") @db.VarChar(100) + avatar String? @map("avatar") @db.VarChar(255) + + // === 时间戳 === + createdAt DateTime @default(now()) @map("created_at") + + @@unique([leaderboardType, periodKey, displayPosition], name: "uk_vr_type_period_pos") + @@map("virtual_ranking_entries") + @@index([virtualAccountId], name: "idx_vr_va") + @@index([leaderboardType, periodKey], name: "idx_vr_type_period") +} + +// ============================================ +// 榜单历史快照表 +// 保存每个周期结束时的完整榜单数据 +// ============================================ +model LeaderboardSnapshot { + id BigInt @id @default(autoincrement()) @map("snapshot_id") + + // === 榜单信息 === + leaderboardType String @map("leaderboard_type") @db.VarChar(30) + periodKey String @map("period_key") @db.VarChar(20) + + // === 快照数据 === + rankingsData Json @map("rankings_data") // 完整排名数据 + + // === 统计信息 === + totalParticipants Int @map("total_participants") // 参与人数 + topScore Int @map("top_score") // 最高分 + averageScore Int @map("average_score") // 平均分 + + // === 时间戳 === + periodStartAt DateTime @map("period_start_at") + periodEndAt DateTime @map("period_end_at") + snapshotAt DateTime @default(now()) @map("snapshot_at") + + @@unique([leaderboardType, periodKey], name: "uk_snapshot_type_period") + @@map("leaderboard_snapshots") + @@index([leaderboardType], name: "idx_snapshot_type") + @@index([periodKey], name: "idx_snapshot_period") +} + +// ============================================ +// 虚拟账户交易记录表 +// 记录省市公司账户的资金变动 +// ============================================ +model VirtualAccountTransaction { + id BigInt @id @default(autoincrement()) @map("transaction_id") + virtualAccountId BigInt @map("virtual_account_id") + + // === 交易信息 === + transactionType String @map("transaction_type") @db.VarChar(30) // INCOME / EXPENSE + amount Decimal @map("amount") @db.Decimal(20, 8) + currency String @map("currency") @db.VarChar(10) // USDT / HASHPOWER + + // === 来源信息 === + sourceType String? @map("source_type") @db.VarChar(50) // PLANTING_REWARD / MANUAL + sourceId String? @map("source_id") @db.VarChar(100) + sourceUserId BigInt? @map("source_user_id") + + // === 备注 === + memo String? @map("memo") @db.VarChar(500) + + // === 时间戳 === + createdAt DateTime @default(now()) @map("created_at") + + @@map("virtual_account_transactions") + @@index([virtualAccountId], name: "idx_vat_account") + @@index([transactionType], name: "idx_vat_type") + @@index([createdAt(sort: Desc)], name: "idx_vat_created") +} + +// ============================================ +// 龙虎榜事件表 +// ============================================ +model LeaderboardEvent { + id BigInt @id @default(autoincrement()) @map("event_id") + eventType String @map("event_type") @db.VarChar(50) + + // 聚合根信息 + aggregateId String @map("aggregate_id") @db.VarChar(100) + aggregateType String @map("aggregate_type") @db.VarChar(50) + + // 事件数据 + eventData Json @map("event_data") + + // 元数据 + userId BigInt? @map("user_id") + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6) + version Int @default(1) @map("version") + + @@map("leaderboard_events") + @@index([aggregateType, aggregateId], name: "idx_lb_event_aggregate") + @@index([eventType], name: "idx_lb_event_type") + @@index([occurredAt], name: "idx_lb_event_occurred") +} +``` + +### 2.2 初始化数据库和种子数据 + +```bash +npx prisma generate +npx prisma migrate dev --name init +``` + +创建 `prisma/seed.ts`: +```typescript +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // 初始化全局配置 + await prisma.leaderboardConfig.upsert({ + where: { configKey: 'GLOBAL' }, + update: {}, + create: { + configKey: 'GLOBAL', + dailyEnabled: true, + weeklyEnabled: true, + monthlyEnabled: true, + virtualRankingEnabled: false, + virtualAccountCount: 0, + displayLimit: 30, + refreshIntervalMinutes: 5, + }, + }); + + // 初始化总部社区虚拟账户 + await prisma.virtualAccount.upsert({ + where: { id: 1n }, + update: {}, + create: { + accountType: 'HEADQUARTERS', + displayName: '总部社区', + isActive: true, + }, + }); + + console.log('Seed completed: Leaderboard config and headquarters account initialized'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); +``` + +--- + +## 第三阶段:领域层实现 (Domain Layer) + +### 3.1 值对象 (Value Objects) + +#### 3.1.1 src/domain/value-objects/leaderboard-type.enum.ts +```typescript +export enum LeaderboardType { + DAILY = 'DAILY', // 日榜 + WEEKLY = 'WEEKLY', // 周榜 + MONTHLY = 'MONTHLY', // 月榜 +} + +export const LeaderboardTypeLabels: Record = { + [LeaderboardType.DAILY]: '日榜', + [LeaderboardType.WEEKLY]: '周榜', + [LeaderboardType.MONTHLY]: '月榜', +}; +``` + +#### 3.1.2 src/domain/value-objects/leaderboard-period.vo.ts +```typescript +export class LeaderboardPeriod { + private constructor( + public readonly type: LeaderboardType, + public readonly key: string, // 2024-01-15 / 2024-W03 / 2024-01 + public readonly startAt: Date, + public readonly endAt: Date, + ) {} + + /** + * 创建当前日榜周期 + */ + static currentDaily(): LeaderboardPeriod { + const now = new Date(); + const startAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + const endAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); + const key = this.formatDate(now); + + return new LeaderboardPeriod(LeaderboardType.DAILY, key, startAt, endAt); + } + + /** + * 创建当前周榜周期 + */ + static currentWeekly(): LeaderboardPeriod { + const now = new Date(); + const dayOfWeek = now.getDay(); + const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + + const monday = new Date(now); + monday.setDate(now.getDate() + diffToMonday); + monday.setHours(0, 0, 0, 0); + + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + sunday.setHours(23, 59, 59, 999); + + const weekNumber = this.getWeekNumber(now); + const key = `${now.getFullYear()}-W${weekNumber.toString().padStart(2, '0')}`; + + return new LeaderboardPeriod(LeaderboardType.WEEKLY, key, monday, sunday); + } + + /** + * 创建当前月榜周期 + */ + static currentMonthly(): LeaderboardPeriod { + const now = new Date(); + const startAt = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); + const endAt = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + const key = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`; + + return new LeaderboardPeriod(LeaderboardType.MONTHLY, key, startAt, endAt); + } + + /** + * 根据类型创建当前周期 + */ + static current(type: LeaderboardType): LeaderboardPeriod { + switch (type) { + case LeaderboardType.DAILY: + return this.currentDaily(); + case LeaderboardType.WEEKLY: + return this.currentWeekly(); + case LeaderboardType.MONTHLY: + return this.currentMonthly(); + } + } + + /** + * 检查是否在当前周期内 + */ + isCurrentPeriod(): boolean { + const now = new Date(); + return now >= this.startAt && now <= this.endAt; + } + + private static formatDate(date: Date): string { + return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; + } + + private static getWeekNumber(date: Date): number { + const firstDayOfYear = new Date(date.getFullYear(), 0, 1); + const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000; + return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); + } +} +``` + +#### 3.1.3 src/domain/value-objects/ranking-score.vo.ts +```typescript +/** + * 龙虎榜分值值对象 + * + * 计算公式: 团队总认种量 - 最大单个直推团队认种量 + */ +export class RankingScore { + private constructor( + public readonly totalTeamPlanting: number, // 团队总认种量 + public readonly maxDirectTeamPlanting: number, // 最大单个直推团队认种量 + public readonly effectiveScore: number, // 有效分值(龙虎榜分值) + ) {} + + static calculate( + totalTeamPlanting: number, + maxDirectTeamPlanting: number, + ): RankingScore { + const effectiveScore = Math.max(0, totalTeamPlanting - maxDirectTeamPlanting); + return new RankingScore(totalTeamPlanting, maxDirectTeamPlanting, effectiveScore); + } + + static zero(): RankingScore { + return new RankingScore(0, 0, 0); + } + + static fromRaw( + totalTeamPlanting: number, + maxDirectTeamPlanting: number, + effectiveScore: number, + ): RankingScore { + return new RankingScore(totalTeamPlanting, maxDirectTeamPlanting, effectiveScore); + } + + /** + * 比较分值(用于排序) + */ + compareTo(other: RankingScore): number { + return other.effectiveScore - this.effectiveScore; + } + + equals(other: RankingScore): boolean { + return this.effectiveScore === other.effectiveScore; + } +} +``` + +#### 3.1.4 src/domain/value-objects/rank-position.vo.ts +```typescript +export class RankPosition { + private constructor( + public readonly value: number, + ) { + if (value < 1) { + throw new Error('排名必须大于0'); + } + } + + static create(value: number): RankPosition { + return new RankPosition(value); + } + + /** + * 是否在前N名 + */ + isTop(n: number): boolean { + return this.value <= n; + } + + /** + * 计算排名变化 + */ + calculateChange(previousRank: RankPosition | null): number { + if (!previousRank) return 0; + return previousRank.value - this.value; + } + + equals(other: RankPosition): boolean { + return this.value === other.value; + } +} +``` + +#### 3.1.5 src/domain/value-objects/user-snapshot.vo.ts +```typescript +export class UserSnapshot { + private constructor( + public readonly userId: bigint, + public readonly nickname: string, + public readonly avatar: string | null, + public readonly accountNo: string | null, + ) {} + + static create(params: { + userId: bigint; + nickname: string; + avatar?: string | null; + accountNo?: string | null; + }): UserSnapshot { + return new UserSnapshot( + params.userId, + params.nickname, + params.avatar || null, + params.accountNo || null, + ); + } + + static fromJson(json: Record): UserSnapshot { + return new UserSnapshot( + BigInt(json.userId), + json.nickname, + json.avatar || null, + json.accountNo || null, + ); + } + + toJson(): Record { + return { + userId: this.userId.toString(), + nickname: this.nickname, + avatar: this.avatar, + accountNo: this.accountNo, + }; + } +} +``` + +#### 3.1.6 src/domain/value-objects/virtual-account-type.enum.ts +```typescript +export enum VirtualAccountType { + RANKING_VIRTUAL = 'RANKING_VIRTUAL', // 排名虚拟账户 + SYSTEM_PROVINCE = 'SYSTEM_PROVINCE', // 系统省公司 + SYSTEM_CITY = 'SYSTEM_CITY', // 系统市公司 + HEADQUARTERS = 'HEADQUARTERS', // 总部社区 +} +``` + +#### 3.1.7 src/domain/value-objects/index.ts +```typescript +export * from './leaderboard-type.enum'; +export * from './leaderboard-period.vo'; +export * from './ranking-score.vo'; +export * from './rank-position.vo'; +export * from './user-snapshot.vo'; +export * from './virtual-account-type.enum'; +``` + +### 3.2 领域事件 (Domain Events) + +#### 3.2.1 src/domain/events/domain-event.base.ts +```typescript +import { v4 as uuidv4 } from 'uuid'; + +export abstract class DomainEvent { + public readonly eventId: string; + public readonly occurredAt: Date; + public readonly version: number; + + protected constructor(version: number = 1) { + this.eventId = uuidv4(); + this.occurredAt = new Date(); + this.version = version; + } + + abstract get eventType(): string; + abstract get aggregateId(): string; + abstract get aggregateType(): string; + abstract toPayload(): Record; +} +``` + +#### 3.2.2 src/domain/events/leaderboard-refreshed.event.ts +```typescript +import { DomainEvent } from './domain-event.base'; +import { LeaderboardType } from '../value-objects/leaderboard-type.enum'; + +export interface LeaderboardRefreshedPayload { + leaderboardType: LeaderboardType; + periodKey: string; + totalParticipants: number; + topScore: number; + refreshedAt: Date; +} + +export class LeaderboardRefreshedEvent extends DomainEvent { + constructor(private readonly payload: LeaderboardRefreshedPayload) { + super(); + } + + get eventType(): string { + return 'LeaderboardRefreshed'; + } + + get aggregateId(): string { + return `${this.payload.leaderboardType}_${this.payload.periodKey}`; + } + + get aggregateType(): string { + return 'Leaderboard'; + } + + toPayload(): LeaderboardRefreshedPayload { + return { ...this.payload }; + } +} +``` + +#### 3.2.3 src/domain/events/config-updated.event.ts +```typescript +import { DomainEvent } from './domain-event.base'; + +export interface ConfigUpdatedPayload { + configKey: string; + changes: Record; + updatedBy: string; +} + +export class ConfigUpdatedEvent extends DomainEvent { + constructor(private readonly payload: ConfigUpdatedPayload) { + super(); + } + + get eventType(): string { + return 'LeaderboardConfigUpdated'; + } + + get aggregateId(): string { + return this.payload.configKey; + } + + get aggregateType(): string { + return 'LeaderboardConfig'; + } + + toPayload(): ConfigUpdatedPayload { + return { ...this.payload }; + } +} +``` + +### 3.3 聚合根 (Aggregates) + +#### 3.3.1 src/domain/aggregates/leaderboard-ranking/leaderboard-ranking.aggregate.ts +```typescript +import { DomainEvent } from '../../events/domain-event.base'; +import { LeaderboardType } from '../../value-objects/leaderboard-type.enum'; +import { LeaderboardPeriod } from '../../value-objects/leaderboard-period.vo'; +import { RankingScore } from '../../value-objects/ranking-score.vo'; +import { RankPosition } from '../../value-objects/rank-position.vo'; +import { UserSnapshot } from '../../value-objects/user-snapshot.vo'; + +/** + * 龙虎榜排名聚合根 + * + * 不变式: + * 1. 同一榜单内排名必须唯一且连续 + * 2. 虚拟账户不参与真实排名计算 + * 3. 有效分值 = 团队总认种 - 最大单个直推团队认种 + */ +export class LeaderboardRanking { + private _id: bigint | null = null; + private readonly _leaderboardType: LeaderboardType; + private readonly _period: LeaderboardPeriod; + private readonly _userId: bigint; + private readonly _isVirtual: boolean; + private _rankPosition: RankPosition; + private _displayPosition: RankPosition; + private _previousRank: RankPosition | null; + private _score: RankingScore; + private readonly _userSnapshot: UserSnapshot; + private readonly _calculatedAt: Date; + + private _domainEvents: DomainEvent[] = []; + + private constructor( + leaderboardType: LeaderboardType, + period: LeaderboardPeriod, + userId: bigint, + isVirtual: boolean, + rankPosition: RankPosition, + displayPosition: RankPosition, + previousRank: RankPosition | null, + score: RankingScore, + userSnapshot: UserSnapshot, + calculatedAt: Date, + ) { + this._leaderboardType = leaderboardType; + this._period = period; + this._userId = userId; + this._isVirtual = isVirtual; + this._rankPosition = rankPosition; + this._displayPosition = displayPosition; + this._previousRank = previousRank; + this._score = score; + this._userSnapshot = userSnapshot; + this._calculatedAt = calculatedAt; + } + + // ============ Getters ============ + get id(): bigint | null { return this._id; } + get leaderboardType(): LeaderboardType { return this._leaderboardType; } + get period(): LeaderboardPeriod { return this._period; } + get periodKey(): string { return this._period.key; } + get userId(): bigint { return this._userId; } + get isVirtual(): boolean { return this._isVirtual; } + get rankPosition(): RankPosition { return this._rankPosition; } + get displayPosition(): RankPosition { return this._displayPosition; } + get previousRank(): RankPosition | null { return this._previousRank; } + get score(): RankingScore { return this._score; } + get userSnapshot(): UserSnapshot { return this._userSnapshot; } + get calculatedAt(): Date { return this._calculatedAt; } + get domainEvents(): DomainEvent[] { return [...this._domainEvents]; } + + get rankChange(): number { + return this._displayPosition.calculateChange(this._previousRank); + } + + // ============ 工厂方法 ============ + + /** + * 创建真实用户排名 + */ + static createRealRanking(params: { + leaderboardType: LeaderboardType; + period: LeaderboardPeriod; + userId: bigint; + rankPosition: number; + displayPosition: number; + previousRank: number | null; + totalTeamPlanting: number; + maxDirectTeamPlanting: number; + userSnapshot: UserSnapshot; + }): LeaderboardRanking { + const score = RankingScore.calculate( + params.totalTeamPlanting, + params.maxDirectTeamPlanting, + ); + + return new LeaderboardRanking( + params.leaderboardType, + params.period, + params.userId, + false, + RankPosition.create(params.rankPosition), + RankPosition.create(params.displayPosition), + params.previousRank ? RankPosition.create(params.previousRank) : null, + score, + params.userSnapshot, + new Date(), + ); + } + + /** + * 创建虚拟用户排名 + */ + static createVirtualRanking(params: { + leaderboardType: LeaderboardType; + period: LeaderboardPeriod; + virtualAccountId: bigint; + displayPosition: number; + generatedScore: number; + displayName: string; + avatar: string | null; + }): LeaderboardRanking { + const userSnapshot = UserSnapshot.create({ + userId: params.virtualAccountId, + nickname: params.displayName, + avatar: params.avatar, + }); + + return new LeaderboardRanking( + params.leaderboardType, + params.period, + params.virtualAccountId, + true, + RankPosition.create(params.displayPosition), // 虚拟账户的实际排名等于显示排名 + RankPosition.create(params.displayPosition), + null, + RankingScore.fromRaw(params.generatedScore, 0, params.generatedScore), + userSnapshot, + new Date(), + ); + } + + // ============ 领域行为 ============ + + /** + * 更新显示排名(虚拟排名插入后调整) + */ + updateDisplayPosition(newDisplayPosition: number): void { + this._displayPosition = RankPosition.create(newDisplayPosition); + } + + /** + * 是否在显示范围内 + */ + isWithinDisplayLimit(limit: number): boolean { + return this._displayPosition.isTop(limit); + } + + setId(id: bigint): void { + this._id = id; + } + + clearDomainEvents(): void { + this._domainEvents = []; + } + + // ============ 重建 ============ + + static reconstitute(data: { + id: bigint; + leaderboardType: LeaderboardType; + periodKey: string; + periodStartAt: Date; + periodEndAt: Date; + userId: bigint; + isVirtual: boolean; + rankPosition: number; + displayPosition: number; + previousRank: number | null; + totalTeamPlanting: number; + maxDirectTeamPlanting: number; + effectiveScore: number; + userSnapshot: Record; + calculatedAt: Date; + }): LeaderboardRanking { + const period = new LeaderboardPeriod( + data.leaderboardType, + data.periodKey, + data.periodStartAt, + data.periodEndAt, + ); + + const ranking = new LeaderboardRanking( + data.leaderboardType, + period, + data.userId, + data.isVirtual, + RankPosition.create(data.rankPosition), + RankPosition.create(data.displayPosition), + data.previousRank ? RankPosition.create(data.previousRank) : null, + RankingScore.fromRaw( + data.totalTeamPlanting, + data.maxDirectTeamPlanting, + data.effectiveScore, + ), + UserSnapshot.fromJson(data.userSnapshot), + data.calculatedAt, + ); + ranking._id = data.id; + return ranking; + } +} +``` + +#### 3.3.2 src/domain/aggregates/leaderboard-config/leaderboard-config.aggregate.ts +```typescript +import { DomainEvent } from '../../events/domain-event.base'; +import { ConfigUpdatedEvent } from '../../events/config-updated.event'; + +/** + * 龙虎榜配置聚合根 + * + * 不变式: + * 1. 虚拟账户数量不能为负数 + * 2. 显示数量必须大于0 + * 3. 刷新间隔必须大于0 + */ +export class LeaderboardConfig { + private _id: bigint | null = null; + private readonly _configKey: string; + + // 榜单开关 + private _dailyEnabled: boolean; + private _weeklyEnabled: boolean; + private _monthlyEnabled: boolean; + + // 虚拟排名设置 + private _virtualRankingEnabled: boolean; + private _virtualAccountCount: number; + + // 显示设置 + private _displayLimit: number; + + // 刷新设置 + private _refreshIntervalMinutes: number; + + private readonly _createdAt: Date; + + private _domainEvents: DomainEvent[] = []; + + private constructor( + configKey: string, + dailyEnabled: boolean, + weeklyEnabled: boolean, + monthlyEnabled: boolean, + virtualRankingEnabled: boolean, + virtualAccountCount: number, + displayLimit: number, + refreshIntervalMinutes: number, + ) { + this._configKey = configKey; + this._dailyEnabled = dailyEnabled; + this._weeklyEnabled = weeklyEnabled; + this._monthlyEnabled = monthlyEnabled; + this._virtualRankingEnabled = virtualRankingEnabled; + this._virtualAccountCount = virtualAccountCount; + this._displayLimit = displayLimit; + this._refreshIntervalMinutes = refreshIntervalMinutes; + this._createdAt = new Date(); + } + + // ============ Getters ============ + get id(): bigint | null { return this._id; } + get configKey(): string { return this._configKey; } + get dailyEnabled(): boolean { return this._dailyEnabled; } + get weeklyEnabled(): boolean { return this._weeklyEnabled; } + get monthlyEnabled(): boolean { return this._monthlyEnabled; } + get virtualRankingEnabled(): boolean { return this._virtualRankingEnabled; } + get virtualAccountCount(): number { return this._virtualAccountCount; } + get displayLimit(): number { return this._displayLimit; } + get refreshIntervalMinutes(): number { return this._refreshIntervalMinutes; } + get createdAt(): Date { return this._createdAt; } + get domainEvents(): DomainEvent[] { return [...this._domainEvents]; } + + // ============ 工厂方法 ============ + + static createDefault(): LeaderboardConfig { + return new LeaderboardConfig( + 'GLOBAL', + true, // dailyEnabled + true, // weeklyEnabled + true, // monthlyEnabled + false, // virtualRankingEnabled + 0, // virtualAccountCount + 30, // displayLimit + 5, // refreshIntervalMinutes + ); + } + + // ============ 领域行为 ============ + + /** + * 更新榜单开关 + */ + updateLeaderboardSwitch( + type: 'daily' | 'weekly' | 'monthly', + enabled: boolean, + updatedBy: string, + ): void { + const changes: Record = {}; + + switch (type) { + case 'daily': + this._dailyEnabled = enabled; + changes.dailyEnabled = enabled; + break; + case 'weekly': + this._weeklyEnabled = enabled; + changes.weeklyEnabled = enabled; + break; + case 'monthly': + this._monthlyEnabled = enabled; + changes.monthlyEnabled = enabled; + break; + } + + this._domainEvents.push(new ConfigUpdatedEvent({ + configKey: this._configKey, + changes, + updatedBy, + })); + } + + /** + * 更新虚拟排名设置 + */ + updateVirtualRankingSettings( + enabled: boolean, + accountCount: number, + updatedBy: string, + ): void { + if (accountCount < 0) { + throw new Error('虚拟账户数量不能为负数'); + } + + this._virtualRankingEnabled = enabled; + this._virtualAccountCount = accountCount; + + this._domainEvents.push(new ConfigUpdatedEvent({ + configKey: this._configKey, + changes: { + virtualRankingEnabled: enabled, + virtualAccountCount: accountCount, + }, + updatedBy, + })); + } + + /** + * 更新显示数量 + */ + updateDisplayLimit(limit: number, updatedBy: string): void { + if (limit <= 0) { + throw new Error('显示数量必须大于0'); + } + + this._displayLimit = limit; + + this._domainEvents.push(new ConfigUpdatedEvent({ + configKey: this._configKey, + changes: { displayLimit: limit }, + updatedBy, + })); + } + + /** + * 更新刷新间隔 + */ + updateRefreshInterval(minutes: number, updatedBy: string): void { + if (minutes <= 0) { + throw new Error('刷新间隔必须大于0'); + } + + this._refreshIntervalMinutes = minutes; + + this._domainEvents.push(new ConfigUpdatedEvent({ + configKey: this._configKey, + changes: { refreshIntervalMinutes: minutes }, + updatedBy, + })); + } + + /** + * 检查榜单是否启用 + */ + isLeaderboardEnabled(type: LeaderboardType): boolean { + switch (type) { + case LeaderboardType.DAILY: + return this._dailyEnabled; + case LeaderboardType.WEEKLY: + return this._weeklyEnabled; + case LeaderboardType.MONTHLY: + return this._monthlyEnabled; + default: + return false; + } + } + + setId(id: bigint): void { + this._id = id; + } + + clearDomainEvents(): void { + this._domainEvents = []; + } + + // ============ 重建 ============ + + static reconstitute(data: { + id: bigint; + configKey: string; + dailyEnabled: boolean; + weeklyEnabled: boolean; + monthlyEnabled: boolean; + virtualRankingEnabled: boolean; + virtualAccountCount: number; + displayLimit: number; + refreshIntervalMinutes: number; + }): LeaderboardConfig { + const config = new LeaderboardConfig( + data.configKey, + data.dailyEnabled, + data.weeklyEnabled, + data.monthlyEnabled, + data.virtualRankingEnabled, + data.virtualAccountCount, + data.displayLimit, + data.refreshIntervalMinutes, + ); + config._id = data.id; + return config; + } +} + +import { LeaderboardType } from '../../value-objects/leaderboard-type.enum'; +``` + +### 3.4 实体 (Entities) + +#### 3.4.1 src/domain/entities/virtual-account.entity.ts +```typescript +import { VirtualAccountType } from '../value-objects/virtual-account-type.enum'; + +/** + * 虚拟账户实体 + */ +export class VirtualAccount { + private _id: bigint | null = null; + private readonly _accountType: VirtualAccountType; + private _displayName: string; + private _avatar: string | null; + private readonly _provinceCode: string | null; + private readonly _cityCode: string | null; + private _minScore: number | null; + private _maxScore: number | null; + private _currentScore: number; + private _usdtBalance: number; + private _hashpowerBalance: number; + private _isActive: boolean; + private readonly _createdAt: Date; + + private constructor( + accountType: VirtualAccountType, + displayName: string, + avatar: string | null, + provinceCode: string | null, + cityCode: string | null, + minScore: number | null, + maxScore: number | null, + ) { + this._accountType = accountType; + this._displayName = displayName; + this._avatar = avatar; + this._provinceCode = provinceCode; + this._cityCode = cityCode; + this._minScore = minScore; + this._maxScore = maxScore; + this._currentScore = 0; + this._usdtBalance = 0; + this._hashpowerBalance = 0; + this._isActive = true; + this._createdAt = new Date(); + } + + // ============ Getters ============ + get id(): bigint | null { return this._id; } + get accountType(): VirtualAccountType { return this._accountType; } + get displayName(): string { return this._displayName; } + get avatar(): string | null { return this._avatar; } + get provinceCode(): string | null { return this._provinceCode; } + get cityCode(): string | null { return this._cityCode; } + get minScore(): number | null { return this._minScore; } + get maxScore(): number | null { return this._maxScore; } + get currentScore(): number { return this._currentScore; } + get usdtBalance(): number { return this._usdtBalance; } + get hashpowerBalance(): number { return this._hashpowerBalance; } + get isActive(): boolean { return this._isActive; } + get createdAt(): Date { return this._createdAt; } + + // ============ 工厂方法 ============ + + /** + * 创建排名虚拟账户 + */ + static createRankingVirtual(params: { + displayName: string; + avatar?: string; + minScore: number; + maxScore: number; + }): VirtualAccount { + return new VirtualAccount( + VirtualAccountType.RANKING_VIRTUAL, + params.displayName, + params.avatar || null, + null, + null, + params.minScore, + params.maxScore, + ); + } + + /** + * 创建系统省公司账户 + */ + static createSystemProvince(provinceCode: string, provinceName: string): VirtualAccount { + return new VirtualAccount( + VirtualAccountType.SYSTEM_PROVINCE, + `系统省公司-${provinceName}`, + null, + provinceCode, + null, + null, + null, + ); + } + + /** + * 创建系统市公司账户 + */ + static createSystemCity(cityCode: string, cityName: string): VirtualAccount { + return new VirtualAccount( + VirtualAccountType.SYSTEM_CITY, + `系统市公司-${cityName}`, + null, + null, + cityCode, + null, + null, + ); + } + + /** + * 创建总部社区账户 + */ + static createHeadquarters(): VirtualAccount { + return new VirtualAccount( + VirtualAccountType.HEADQUARTERS, + '总部社区', + null, + null, + null, + null, + null, + ); + } + + // ============ 领域行为 ============ + + /** + * 生成随机分值 + */ + generateRandomScore(): number { + if (this._minScore === null || this._maxScore === null) { + return 0; + } + this._currentScore = Math.floor( + Math.random() * (this._maxScore - this._minScore + 1) + this._minScore + ); + return this._currentScore; + } + + /** + * 增加余额 + */ + addBalance(usdtAmount: number, hashpowerAmount: number): void { + this._usdtBalance += usdtAmount; + this._hashpowerBalance += hashpowerAmount; + } + + /** + * 扣减余额 + */ + deductBalance(usdtAmount: number, hashpowerAmount: number): void { + if (this._usdtBalance < usdtAmount) { + throw new Error('USDT余额不足'); + } + if (this._hashpowerBalance < hashpowerAmount) { + throw new Error('算力余额不足'); + } + this._usdtBalance -= usdtAmount; + this._hashpowerBalance -= hashpowerAmount; + } + + /** + * 激活账户 + */ + activate(): void { + this._isActive = true; + } + + /** + * 停用账户 + */ + deactivate(): void { + this._isActive = false; + } + + /** + * 更新显示信息 + */ + updateDisplayInfo(displayName: string, avatar?: string): void { + this._displayName = displayName; + if (avatar !== undefined) { + this._avatar = avatar; + } + } + + /** + * 更新分值范围 + */ + updateScoreRange(minScore: number, maxScore: number): void { + if (minScore > maxScore) { + throw new Error('最小分值不能大于最大分值'); + } + this._minScore = minScore; + this._maxScore = maxScore; + } + + setId(id: bigint): void { + this._id = id; + } + + // ============ 重建 ============ + + static reconstitute(data: { + id: bigint; + accountType: VirtualAccountType; + displayName: string; + avatar: string | null; + provinceCode: string | null; + cityCode: string | null; + minScore: number | null; + maxScore: number | null; + currentScore: number; + usdtBalance: number; + hashpowerBalance: number; + isActive: boolean; + createdAt: Date; + }): VirtualAccount { + const account = new VirtualAccount( + data.accountType, + data.displayName, + data.avatar, + data.provinceCode, + data.cityCode, + data.minScore, + data.maxScore, + ); + account._id = data.id; + account._currentScore = data.currentScore; + account._usdtBalance = data.usdtBalance; + account._hashpowerBalance = data.hashpowerBalance; + account._isActive = data.isActive; + return account; + } +} +``` + +### 3.5 仓储接口 (Repository Interfaces) + +#### 3.5.1 src/domain/repositories/leaderboard-ranking.repository.interface.ts +```typescript +import { LeaderboardRanking } from '../aggregates/leaderboard-ranking/leaderboard-ranking.aggregate'; +import { LeaderboardType } from '../value-objects/leaderboard-type.enum'; + +export interface ILeaderboardRankingRepository { + save(ranking: LeaderboardRanking): Promise; + saveAll(rankings: LeaderboardRanking[]): Promise; + findById(id: bigint): Promise; + + findByTypeAndPeriod( + type: LeaderboardType, + periodKey: string, + options?: { + limit?: number; + includeVirtual?: boolean; + }, + ): Promise; + + findUserRanking( + type: LeaderboardType, + periodKey: string, + userId: bigint, + ): Promise; + + findUserPreviousRanking( + type: LeaderboardType, + userId: bigint, + ): Promise; + + deleteByTypeAndPeriod( + type: LeaderboardType, + periodKey: string, + ): Promise; + + countByTypeAndPeriod( + type: LeaderboardType, + periodKey: string, + ): Promise; +} + +export const LEADERBOARD_RANKING_REPOSITORY = Symbol('ILeaderboardRankingRepository'); +``` + +#### 3.5.2 src/domain/repositories/leaderboard-config.repository.interface.ts +```typescript +import { LeaderboardConfig } from '../aggregates/leaderboard-config/leaderboard-config.aggregate'; + +export interface ILeaderboardConfigRepository { + save(config: LeaderboardConfig): Promise; + findByKey(configKey: string): Promise; + getGlobalConfig(): Promise; +} + +export const LEADERBOARD_CONFIG_REPOSITORY = Symbol('ILeaderboardConfigRepository'); +``` + +#### 3.5.3 src/domain/repositories/virtual-account.repository.interface.ts +```typescript +import { VirtualAccount } from '../entities/virtual-account.entity'; +import { VirtualAccountType } from '../value-objects/virtual-account-type.enum'; + +export interface IVirtualAccountRepository { + save(account: VirtualAccount): Promise; + saveAll(accounts: VirtualAccount[]): Promise; + findById(id: bigint): Promise; + findByType(type: VirtualAccountType): Promise; + findActiveRankingVirtuals(limit: number): Promise; + findByProvinceCode(provinceCode: string): Promise; + findByCityCode(cityCode: string): Promise; + findHeadquarters(): Promise; + countByType(type: VirtualAccountType): Promise; + deleteById(id: bigint): Promise; +} + +export const VIRTUAL_ACCOUNT_REPOSITORY = Symbol('IVirtualAccountRepository'); +``` + +### 3.6 领域服务 (Domain Services) + +#### 3.6.1 src/domain/services/leaderboard-calculation.service.ts +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { LeaderboardRanking } from '../aggregates/leaderboard-ranking/leaderboard-ranking.aggregate'; +import { LeaderboardType } from '../value-objects/leaderboard-type.enum'; +import { LeaderboardPeriod } from '../value-objects/leaderboard-period.vo'; +import { UserSnapshot } from '../value-objects/user-snapshot.vo'; + +// 外部服务接口(防腐层) +export interface IReferralServiceClient { + getTeamStatisticsForLeaderboard(params: { + periodStartAt: Date; + periodEndAt: Date; + limit: number; + }): Promise>; +} + +export interface IIdentityServiceClient { + getUserSnapshots(userIds: bigint[]): Promise>; +} + +export const REFERRAL_SERVICE_CLIENT = Symbol('IReferralServiceClient'); +export const IDENTITY_SERVICE_CLIENT = Symbol('IIdentityServiceClient'); + +@Injectable() +export class LeaderboardCalculationService { + constructor( + @Inject(REFERRAL_SERVICE_CLIENT) + private readonly referralService: IReferralServiceClient, + @Inject(IDENTITY_SERVICE_CLIENT) + private readonly identityService: IIdentityServiceClient, + ) {} + + /** + * 计算龙虎榜排名 + */ + async calculateRankings( + type: LeaderboardType, + limit: number = 100, + ): Promise { + const period = LeaderboardPeriod.current(type); + + // 1. 从 Referral Service 获取团队统计数据 + const teamStats = await this.referralService.getTeamStatisticsForLeaderboard({ + periodStartAt: period.startAt, + periodEndAt: period.endAt, + limit, + }); + + if (teamStats.length === 0) { + return []; + } + + // 2. 获取用户信息 + const userIds = teamStats.map(s => s.userId); + const userSnapshots = await this.identityService.getUserSnapshots(userIds); + + // 3. 构建排名列表 + const rankings: LeaderboardRanking[] = []; + + for (let i = 0; i < teamStats.length; i++) { + const stat = teamStats[i]; + const userInfo = userSnapshots.get(stat.userId.toString()); + + if (!userInfo) continue; + + const ranking = LeaderboardRanking.createRealRanking({ + leaderboardType: type, + period, + userId: stat.userId, + rankPosition: i + 1, + displayPosition: i + 1, // 初始显示排名等于实际排名 + previousRank: null, // TODO: 从历史数据获取 + totalTeamPlanting: stat.totalTeamPlanting, + maxDirectTeamPlanting: stat.maxDirectTeamPlanting, + userSnapshot: UserSnapshot.create({ + userId: userInfo.userId, + nickname: userInfo.nickname, + avatar: userInfo.avatar, + accountNo: userInfo.accountNo, + }), + }); + + rankings.push(ranking); + } + + return rankings; + } +} +``` + +#### 3.6.2 src/domain/services/virtual-ranking-generator.service.ts +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { VirtualAccount } from '../entities/virtual-account.entity'; +import { LeaderboardRanking } from '../aggregates/leaderboard-ranking/leaderboard-ranking.aggregate'; +import { LeaderboardType } from '../value-objects/leaderboard-type.enum'; +import { LeaderboardPeriod } from '../value-objects/leaderboard-period.vo'; +import { + IVirtualAccountRepository, + VIRTUAL_ACCOUNT_REPOSITORY, +} from '../repositories/virtual-account.repository.interface'; + +// 随机中文名字库 +const CHINESE_SURNAMES = ['王', '李', '张', '刘', '陈', '杨', '赵', '黄', '周', '吴']; +const CHINESE_NAMES = ['伟', '芳', '娜', '敏', '静', '丽', '强', '磊', '洋', '勇']; + +@Injectable() +export class VirtualRankingGeneratorService { + constructor( + @Inject(VIRTUAL_ACCOUNT_REPOSITORY) + private readonly virtualAccountRepository: IVirtualAccountRepository, + ) {} + + /** + * 生成虚拟排名条目 + */ + async generateVirtualRankings(params: { + type: LeaderboardType; + count: number; + topRealScore: number; + }): Promise { + if (params.count <= 0) { + return []; + } + + const period = LeaderboardPeriod.current(params.type); + + // 1. 获取或创建虚拟账户 + let virtualAccounts = await this.virtualAccountRepository.findActiveRankingVirtuals(params.count); + + // 如果虚拟账户不足,创建新的 + if (virtualAccounts.length < params.count) { + const needed = params.count - virtualAccounts.length; + const newAccounts = await this.createVirtualAccounts(needed, params.topRealScore); + await this.virtualAccountRepository.saveAll(newAccounts); + virtualAccounts = [...virtualAccounts, ...newAccounts]; + } + + // 2. 生成虚拟排名 + const virtualRankings: LeaderboardRanking[] = []; + + // 虚拟账户的分值应该高于真实用户最高分 + const scoreBase = params.topRealScore + 100; + + for (let i = 0; i < params.count; i++) { + const account = virtualAccounts[i]; + + // 生成递减的分值 + const generatedScore = scoreBase + (params.count - i) * 50 + Math.floor(Math.random() * 30); + + const ranking = LeaderboardRanking.createVirtualRanking({ + leaderboardType: params.type, + period, + virtualAccountId: account.id!, + displayPosition: i + 1, // 虚拟账户占据前面的位置 + generatedScore, + displayName: account.displayName, + avatar: account.avatar, + }); + + virtualRankings.push(ranking); + } + + return virtualRankings; + } + + /** + * 创建虚拟账户 + */ + private async createVirtualAccounts(count: number, baseScore: number): Promise { + const accounts: VirtualAccount[] = []; + + for (let i = 0; i < count; i++) { + const displayName = this.generateRandomName(); + const avatar = this.generateRandomAvatar(); + + const account = VirtualAccount.createRankingVirtual({ + displayName, + avatar, + minScore: baseScore, + maxScore: baseScore + 500, + }); + + accounts.push(account); + } + + return accounts; + } + + /** + * 生成随机中文名 + */ + private generateRandomName(): string { + const surname = CHINESE_SURNAMES[Math.floor(Math.random() * CHINESE_SURNAMES.length)]; + const name1 = CHINESE_NAMES[Math.floor(Math.random() * CHINESE_NAMES.length)]; + const name2 = Math.random() > 0.5 + ? CHINESE_NAMES[Math.floor(Math.random() * CHINESE_NAMES.length)] + : ''; + + // 部分名字用 * 遮挡 + const maskedName = surname + '*' + (name2 || '*'); + return maskedName; + } + + /** + * 生成随机头像URL + */ + private generateRandomAvatar(): string { + const avatarId = Math.floor(Math.random() * 100) + 1; + return `https://api.dicebear.com/7.x/avataaars/svg?seed=${avatarId}`; + } +} +``` + +#### 3.6.3 src/domain/services/ranking-merger.service.ts +```typescript +import { Injectable } from '@nestjs/common'; +import { LeaderboardRanking } from '../aggregates/leaderboard-ranking/leaderboard-ranking.aggregate'; + +@Injectable() +export class RankingMergerService { + /** + * 合并虚拟排名和真实排名 + * + * 规则: + * - 虚拟账户占据前面的位置 + * - 真实用户排名从虚拟账户数量+1开始 + */ + mergeRankings( + virtualRankings: LeaderboardRanking[], + realRankings: LeaderboardRanking[], + displayLimit: number, + ): LeaderboardRanking[] { + const merged: LeaderboardRanking[] = []; + const virtualCount = virtualRankings.length; + + // 1. 添加虚拟排名 + for (const virtual of virtualRankings) { + if (virtual.displayPosition.value <= displayLimit) { + merged.push(virtual); + } + } + + // 2. 调整真实用户的显示排名并添加 + for (const real of realRankings) { + const newDisplayPosition = real.rankPosition.value + virtualCount; + + if (newDisplayPosition <= displayLimit) { + real.updateDisplayPosition(newDisplayPosition); + merged.push(real); + } + } + + // 3. 按显示排名排序 + merged.sort((a, b) => a.displayPosition.value - b.displayPosition.value); + + return merged; + } + + /** + * 仅获取真实用户排名(不含虚拟) + */ + getRealRankingsOnly( + rankings: LeaderboardRanking[], + displayLimit: number, + ): LeaderboardRanking[] { + return rankings + .filter(r => !r.isVirtual) + .slice(0, displayLimit); + } +} +``` + +--- + +## 领域不变式 (Domain Invariants) + +```typescript +class LeaderboardContextInvariants { + // 1. 同一榜单内排名必须唯一且连续 + static RANK_MUST_BE_UNIQUE_AND_CONTINUOUS = + "同一榜单内排名必须唯一且连续(1,2,3...)" + + // 2. 虚拟账户不参与真实排名计算 + static VIRTUAL_ACCOUNTS_NOT_IN_REAL_RANKING = + "虚拟账户仅用于显示,不参与真实排名计算" + + // 3. 龙虎榜分值计算规则 + static LEADERBOARD_SCORE_FORMULA = + "龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量" + + // 4. 显示排名规则 + static DISPLAY_RANKING_RULE = + "显示排名 = 真实排名 + 虚拟账户数量" + + // 5. 榜单周期规则 + static PERIOD_RULES = + "日榜每日00:00重置,周榜每周一00:00重置,月榜每月1日00:00重置" +} +``` + +--- + +## API 端点设计 + +| 方法 | 路径 | 描述 | 认证 | 权限 | +|------|------|------|------|------| +| GET | `/health` | 健康检查 | 否 | - | +| GET | `/leaderboard` | 获取龙虎榜列表 | JWT | 用户 | +| GET | `/leaderboard/my-ranking` | 获取我的排名 | JWT | 用户 | +| GET | `/leaderboard/config` | 获取榜单配置 | JWT | 管理员 | +| PUT | `/leaderboard/config/switch` | 更新榜单开关 | JWT | 管理员 | +| PUT | `/leaderboard/config/virtual` | 更新虚拟排名设置 | JWT | 管理员 | +| PUT | `/leaderboard/config/display` | 更新显示设置 | JWT | 管理员 | +| POST | `/leaderboard/refresh` | 手动刷新榜单 | JWT | 管理员 | +| GET | `/virtual-accounts` | 获取虚拟账户列表 | JWT | 管理员 | +| POST | `/virtual-accounts/generate` | 生成虚拟账户 | JWT | 管理员 | + +--- + +## 定时任务 + +### 榜单刷新调度器 + +```typescript +// application/schedulers/leaderboard-refresh.scheduler.ts + +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { LeaderboardApplicationService } from '../services/leaderboard-application.service'; +import { LeaderboardType } from '../../domain/value-objects/leaderboard-type.enum'; + +@Injectable() +export class LeaderboardRefreshScheduler { + constructor( + private readonly leaderboardService: LeaderboardApplicationService, + ) {} + + /** + * 每5分钟刷新榜单 + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async refreshAllLeaderboards() { + console.log('开始刷新龙虎榜...'); + + for (const type of Object.values(LeaderboardType)) { + try { + await this.leaderboardService.refreshLeaderboard(type); + console.log(`${type} 榜单刷新完成`); + } catch (error) { + console.error(`${type} 榜单刷新失败:`, error); + } + } + + console.log('龙虎榜刷新完成'); + } + + /** + * 每日00:00保存日榜快照并重置 + */ + @Cron('0 0 0 * * *') + async snapshotDailyLeaderboard() { + console.log('保存日榜快照...'); + await this.leaderboardService.saveSnapshot(LeaderboardType.DAILY); + } + + /** + * 每周一00:00保存周榜快照并重置 + */ + @Cron('0 0 0 * * 1') + async snapshotWeeklyLeaderboard() { + console.log('保存周榜快照...'); + await this.leaderboardService.saveSnapshot(LeaderboardType.WEEKLY); + } + + /** + * 每月1日00:00保存月榜快照并重置 + */ + @Cron('0 0 0 1 * *') + async snapshotMonthlyLeaderboard() { + console.log('保存月榜快照...'); + await this.leaderboardService.saveSnapshot(LeaderboardType.MONTHLY); + } +} +``` + +--- + +## 事件订阅 (Kafka Events) + +### 订阅的事件 + +| Topic | 事件类型 | 触发条件 | 处理逻辑 | +|-------|---------|---------|---------| +| `referral.statistics.updated` | TeamStatisticsUpdatedEvent | 团队统计更新 | 标记榜单需要刷新 | + +### 发布的事件 + +| Topic | 事件类型 | 触发条件 | +|-------|---------|---------| +| `leaderboard.refreshed` | LeaderboardRefreshedEvent | 榜单刷新完成 | +| `leaderboard.config.updated` | ConfigUpdatedEvent | 配置更新 | + +--- + +## 与前端页面对应关系 + +### 龙虎榜页面 + +``` +页面元素: +- 榜单切换标签: 日榜 | 周榜 | 月榜 +- 排名列表: 显示排名、用户头像、用户昵称、龙虎榜分值 +- 我的排名: 显示当前用户在各榜单的排名 +- 榜单状态: 已启用/待开启 + +API调用: +- GET /leaderboard?type=DAILY&limit=30 +- GET /leaderboard/my-ranking?type=DAILY +``` + +### 后台管理 - 龙虎榜配置页面 + +``` +页面元素: +- 榜单开关: 日榜开关、周榜开关、月榜开关 +- 虚拟排名设置: 启用/禁用、虚拟账户数量输入框 +- 显示设置: 前端显示数量输入框 +- 操作按钮: 保存配置、手动刷新榜单 + +API调用: +- GET /leaderboard/config +- PUT /leaderboard/config/switch +- PUT /leaderboard/config/virtual +- PUT /leaderboard/config/display +- POST /leaderboard/refresh +``` + +--- + +## 开发顺序建议 + +1. **Phase 1: 项目初始化** + - 创建NestJS项目 + - 安装依赖 + - 配置环境变量 + +2. **Phase 2: 数据库层** + - 创建Prisma Schema + - 运行迁移和种子数据 + - 创建PrismaService + +3. **Phase 3: 领域层** + - 实现所有值对象 + - 实现聚合根 (LeaderboardRanking, LeaderboardConfig) + - 实现实体 (VirtualAccount) + - 实现领域事件 + - 实现领域服务 + - 编写单元测试 + +4. **Phase 4: 基础设施层** + - 实现仓储 (Repository Implementations) + - 实现外部服务客户端 (ReferralServiceClient, IdentityServiceClient) + - 实现Redis缓存服务 + - 实现Kafka消费者和发布者 + +5. **Phase 5: 应用层** + - 实现应用服务 (LeaderboardApplicationService) + - 实现定时任务 (LeaderboardRefreshScheduler) + - 实现Command/Query handlers + +6. **Phase 6: API层** + - 实现DTO + - 实现Controllers + - 配置Swagger文档 + - 配置JWT认证和管理员权限 + +7. **Phase 7: 测试和部署** + - 集成测试 + - E2E测试 + - Docker配置 + +--- + +## 注意事项 + +1. **榜单数据来源**: 龙虎榜分值从 referral-service 的团队统计数据计算得出 +2. **虚拟排名功能**: 仅用于显示目的,不影响实际奖励分配 +3. **缓存策略**: 榜单数据缓存在 Redis 中,定时刷新 +4. **周期计算**: 注意时区问题,统一使用服务器时区 +5. **性能优化**: 大量用户时考虑分页和缓存 +6. **数据一致性**: 榜单刷新时使用事务确保数据一致性 +7. **历史快照**: 每个周期结束时保存完整快照,便于历史查询