68 KiB
68 KiB
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
开发顺序建议
-
Phase 1: 项目初始化
- 创建NestJS项目
- 安装依赖
- 配置环境变量
-
Phase 2: 数据库层
- 创建Prisma Schema
- 运行迁移和种子数据
- 创建PrismaService
-
Phase 3: 领域层
- 实现所有值对象
- 实现聚合根 (LeaderboardRanking, LeaderboardConfig)
- 实现实体 (VirtualAccount)
- 实现领域事件
- 实现领域服务
- 编写单元测试
-
Phase 4: 基础设施层
- 实现仓储 (Repository Implementations)
- 实现外部服务客户端 (ReferralServiceClient, IdentityServiceClient)
- 实现Redis缓存服务
- 实现Kafka消费者和发布者
-
Phase 5: 应用层
- 实现应用服务 (LeaderboardApplicationService)
- 实现定时任务 (LeaderboardRefreshScheduler)
- 实现Command/Query handlers
-
Phase 6: API层
- 实现DTO
- 实现Controllers
- 配置Swagger文档
- 配置JWT认证和管理员权限
-
Phase 7: 测试和部署
- 集成测试
- E2E测试
- Docker配置
注意事项
- 榜单数据来源: 龙虎榜分值从 referral-service 的团队统计数据计算得出
- 虚拟排名功能: 仅用于显示目的,不影响实际奖励分配
- 缓存策略: 榜单数据缓存在 Redis 中,定时刷新
- 周期计算: 注意时区问题,统一使用服务器时区
- 性能优化: 大量用户时考虑分页和缓存
- 数据一致性: 榜单刷新时使用事务确保数据一致性
- 历史快照: 每个周期结束时保存完整快照,便于历史查询