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

2245 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, string> = {
[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<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
```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<string, any>;
}
```
#### 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<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
```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<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
```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<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
```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<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
```typescript
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
```typescript
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
```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<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
```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<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
```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. **历史快照**: 每个周期结束时保存完整快照,便于历史查询