rwadurian/backend/services/leaderboard-service/DEVELOPMENT_GUIDE.md

68 KiB
Raw Blame History

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 项目

cd backend/services/leaderboard-service
npx @nestjs/cli new . --skip-git --package-manager npm

1.2 安装依赖

# 核心依赖
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:

# 应用配置
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

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 初始化数据库和种子数据

npx prisma generate
npx prisma migrate dev --name init

创建 prisma/seed.ts:

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

export enum LeaderboardType {
  DAILY = 'DAILY',      // 日榜
  WEEKLY = 'WEEKLY',    // 周榜
  MONTHLY = 'MONTHLY',  // 月榜
}

export const LeaderboardTypeLabels: Record<LeaderboardType, string> = {
  [LeaderboardType.DAILY]: '日榜',
  [LeaderboardType.WEEKLY]: '周榜',
  [LeaderboardType.MONTHLY]: '月榜',
};

3.1.2 src/domain/value-objects/leaderboard-period.vo.ts

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

/**
 * 龙虎榜分值值对象
 *
 * 计算公式: 团队总认种量 - 最大单个直推团队认种量
 */
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

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

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<string, any>): UserSnapshot {
    return new UserSnapshot(
      BigInt(json.userId),
      json.nickname,
      json.avatar || null,
      json.accountNo || null,
    );
  }

  toJson(): Record<string, any> {
    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

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

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

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<string, any>;
}

3.2.2 src/domain/events/leaderboard-refreshed.event.ts

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

import { DomainEvent } from './domain-event.base';

export interface ConfigUpdatedPayload {
  configKey: string;
  changes: Record<string, any>;
  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

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<string, any>;
    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

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<string, any> = {};

    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

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

import { LeaderboardRanking } from '../aggregates/leaderboard-ranking/leaderboard-ranking.aggregate';
import { LeaderboardType } from '../value-objects/leaderboard-type.enum';

export interface ILeaderboardRankingRepository {
  save(ranking: LeaderboardRanking): Promise<void>;
  saveAll(rankings: LeaderboardRanking[]): Promise<void>;
  findById(id: bigint): Promise<LeaderboardRanking | null>;

  findByTypeAndPeriod(
    type: LeaderboardType,
    periodKey: string,
    options?: {
      limit?: number;
      includeVirtual?: boolean;
    },
  ): Promise<LeaderboardRanking[]>;

  findUserRanking(
    type: LeaderboardType,
    periodKey: string,
    userId: bigint,
  ): Promise<LeaderboardRanking | null>;

  findUserPreviousRanking(
    type: LeaderboardType,
    userId: bigint,
  ): Promise<LeaderboardRanking | null>;

  deleteByTypeAndPeriod(
    type: LeaderboardType,
    periodKey: string,
  ): Promise<void>;

  countByTypeAndPeriod(
    type: LeaderboardType,
    periodKey: string,
  ): Promise<number>;
}

export const LEADERBOARD_RANKING_REPOSITORY = Symbol('ILeaderboardRankingRepository');

3.5.2 src/domain/repositories/leaderboard-config.repository.interface.ts

import { LeaderboardConfig } from '../aggregates/leaderboard-config/leaderboard-config.aggregate';

export interface ILeaderboardConfigRepository {
  save(config: LeaderboardConfig): Promise<void>;
  findByKey(configKey: string): Promise<LeaderboardConfig | null>;
  getGlobalConfig(): Promise<LeaderboardConfig>;
}

export const LEADERBOARD_CONFIG_REPOSITORY = Symbol('ILeaderboardConfigRepository');

3.5.3 src/domain/repositories/virtual-account.repository.interface.ts

import { VirtualAccount } from '../entities/virtual-account.entity';
import { VirtualAccountType } from '../value-objects/virtual-account-type.enum';

export interface IVirtualAccountRepository {
  save(account: VirtualAccount): Promise<void>;
  saveAll(accounts: VirtualAccount[]): Promise<void>;
  findById(id: bigint): Promise<VirtualAccount | null>;
  findByType(type: VirtualAccountType): Promise<VirtualAccount[]>;
  findActiveRankingVirtuals(limit: number): Promise<VirtualAccount[]>;
  findByProvinceCode(provinceCode: string): Promise<VirtualAccount | null>;
  findByCityCode(cityCode: string): Promise<VirtualAccount | null>;
  findHeadquarters(): Promise<VirtualAccount | null>;
  countByType(type: VirtualAccountType): Promise<number>;
  deleteById(id: bigint): Promise<void>;
}

export const VIRTUAL_ACCOUNT_REPOSITORY = Symbol('IVirtualAccountRepository');

3.6 领域服务 (Domain Services)

3.6.1 src/domain/services/leaderboard-calculation.service.ts

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<Array<{
    userId: bigint;
    totalTeamPlanting: number;
    maxDirectTeamPlanting: number;
    effectiveScore: number;
  }>>;
}

export interface IIdentityServiceClient {
  getUserSnapshots(userIds: bigint[]): Promise<Map<string, {
    userId: bigint;
    nickname: string;
    avatar: string | null;
    accountNo: string | null;
  }>>;
}

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<LeaderboardRanking[]> {
    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

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<LeaderboardRanking[]> {
    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<VirtualAccount[]> {
    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

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)

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 管理员

定时任务

榜单刷新调度器

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