# 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. **历史快照**: 每个周期结束时保存完整快照,便于历史查询