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