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

1981 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Referral Service 开发指导
## 项目概述
Referral Service 是 RWA 榴莲皇后平台的推荐团队微服务,负责管理推荐关系树、团队统计维护、龙虎榜分值计算、省市团队占比统计等功能。
### 核心职责 ✅
- 推荐关系树构建(基于序列号/推荐码)
- 团队统计数据维护
- 上级查找(最近的授权省/市/社区)
- 直推用户管理
- 团队层级关系管理
- 本省/本市认种占比计算
- 龙虎榜分值计算
### 不负责 ❌
- 用户账户管理Identity Context
- 奖励计算Reward Context
- 授权考核判定Authorization Context
---
## 技术栈
| 组件 | 技术选型 |
|------|----------|
| **框架** | NestJS 10.x |
| **数据库** | PostgreSQL + Prisma ORM |
| **架构** | DDD + Hexagonal Architecture (六边形架构) |
| **语言** | TypeScript 5.x |
| **消息队列** | Kafka (kafkajs) |
| **缓存** | Redis (ioredis) |
| **API文档** | Swagger (@nestjs/swagger) |
---
## 架构设计
参考 `identity-service` 的架构模式,严格保持一致性:
```
referral-service/
├── prisma/
│ ├── schema.prisma # 数据库模型定义
│ └── migrations/ # 数据库迁移文件
├── src/
│ ├── api/ # 🔵 Presentation Layer (表现层/API层)
│ │ ├── controllers/
│ │ │ ├── health.controller.ts
│ │ │ ├── referral.controller.ts
│ │ │ ├── team.controller.ts
│ │ │ └── leaderboard.controller.ts
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── create-referral.dto.ts
│ │ │ └── response/
│ │ │ ├── referral-info.dto.ts
│ │ │ ├── team-statistics.dto.ts
│ │ │ ├── direct-referral.dto.ts
│ │ │ └── leaderboard.dto.ts
│ │ └── api.module.ts
│ │
│ ├── application/ # 🟢 Application Layer (应用层)
│ │ ├── commands/
│ │ │ ├── create-referral-relationship/
│ │ │ │ ├── create-referral-relationship.command.ts
│ │ │ │ └── create-referral-relationship.handler.ts
│ │ │ ├── update-team-statistics/
│ │ │ │ ├── update-team-statistics.command.ts
│ │ │ │ └── update-team-statistics.handler.ts
│ │ │ └── index.ts
│ │ ├── queries/
│ │ │ ├── get-my-referral-info/
│ │ │ │ ├── get-my-referral-info.query.ts
│ │ │ │ └── get-my-referral-info.handler.ts
│ │ │ ├── get-direct-referrals/
│ │ │ │ ├── get-direct-referrals.query.ts
│ │ │ │ └── get-direct-referrals.handler.ts
│ │ │ ├── get-team-statistics/
│ │ │ │ ├── get-team-statistics.query.ts
│ │ │ │ └── get-team-statistics.handler.ts
│ │ │ ├── get-referral-chain/
│ │ │ │ ├── get-referral-chain.query.ts
│ │ │ │ └── get-referral-chain.handler.ts
│ │ │ ├── get-leaderboard/
│ │ │ │ ├── get-leaderboard.query.ts
│ │ │ │ └── get-leaderboard.handler.ts
│ │ │ └── index.ts
│ │ ├── services/
│ │ │ └── referral-application.service.ts
│ │ └── application.module.ts
│ │
│ ├── domain/ # 🟡 Domain Layer (领域层) - 核心业务逻辑
│ │ ├── aggregates/
│ │ │ ├── referral-relationship/
│ │ │ │ ├── referral-relationship.aggregate.ts
│ │ │ │ ├── referral-relationship.spec.ts
│ │ │ │ └── index.ts
│ │ │ └── team-statistics/
│ │ │ ├── team-statistics.aggregate.ts
│ │ │ ├── team-statistics.spec.ts
│ │ │ └── index.ts
│ │ ├── value-objects/
│ │ │ ├── referral-code.vo.ts
│ │ │ ├── referral-chain.vo.ts
│ │ │ ├── leaderboard-score.vo.ts
│ │ │ ├── province-city-distribution.vo.ts
│ │ │ ├── user-id.vo.ts
│ │ │ └── index.ts
│ │ ├── events/
│ │ │ ├── domain-event.base.ts
│ │ │ ├── referral-relationship-created.event.ts
│ │ │ ├── team-statistics-updated.event.ts
│ │ │ ├── leaderboard-score-changed.event.ts
│ │ │ └── index.ts
│ │ ├── repositories/
│ │ │ ├── referral-relationship.repository.interface.ts
│ │ │ ├── team-statistics.repository.interface.ts
│ │ │ └── index.ts
│ │ ├── services/
│ │ │ ├── referral-chain.service.ts
│ │ │ ├── leaderboard-calculator.service.ts
│ │ │ ├── team-aggregation.service.ts
│ │ │ └── index.ts
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # 🔴 Infrastructure Layer (基础设施层)
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ ├── mappers/
│ │ │ │ ├── referral-relationship.mapper.ts
│ │ │ │ └── team-statistics.mapper.ts
│ │ │ └── repositories/
│ │ │ ├── referral-relationship.repository.impl.ts
│ │ │ └── team-statistics.repository.impl.ts
│ │ ├── external/
│ │ │ ├── identity-service/
│ │ │ │ └── identity-service.client.ts
│ │ │ └── planting-service/
│ │ │ └── planting-service.client.ts
│ │ ├── kafka/
│ │ │ ├── event-consumer.controller.ts
│ │ │ ├── event-publisher.service.ts
│ │ │ └── kafka.module.ts
│ │ ├── redis/
│ │ │ ├── redis.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
│ │ ├── 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/ # 测试
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── .env.example
├── .env.development
├── .env.production
├── .eslintrc.js
├── .prettierrc
├── nest-cli.json
├── package.json
├── tsconfig.json
├── tsconfig.build.json
└── Dockerfile
```
---
## 第一阶段:项目初始化
### 1.1 创建 NestJS 项目
```bash
cd backend/services/referral-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
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 package.json 配置
```json
{
"name": "referral-service",
"version": "1.0.0",
"description": "RWA Referral & Team Context Service",
"author": "RWA Team",
"private": true,
"license": "UNLICENSED",
"prisma": {
"schema": "prisma/schema.prisma",
"seed": "ts-node prisma/seed.ts"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.0",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.7.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}
```
### 1.4 tsconfig.json 配置
```json
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
},
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
}
}
```
### 1.5 环境变量配置
创建 `.env.development`:
```env
# 应用配置
NODE_ENV=development
PORT=3004
APP_NAME=referral-service
# 数据库
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_referral?schema=public"
# JWT (与 identity-service 共享密钥)
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_ACCESS_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Kafka
KAFKA_BROKERS=localhost:9092
KAFKA_GROUP_ID=referral-service-group
KAFKA_CLIENT_ID=referral-service
# 外部服务
IDENTITY_SERVICE_URL=http://localhost:3001
PLANTING_SERVICE_URL=http://localhost:3003
```
---
## 第二阶段:数据库设计 (Prisma Schema)
### 2.1 创建 prisma/schema.prisma
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// 推荐关系表 (聚合根1)
// 记录用户与推荐人的关系,推荐关系一旦建立终生不可修改
// ============================================
model ReferralRelationship {
id BigInt @id @default(autoincrement()) @map("relationship_id")
userId BigInt @unique @map("user_id")
// 推荐人信息
referrerId BigInt? @map("referrer_id") // 直接推荐人 (null = 无推荐人/根节点)
rootUserId BigInt? @map("root_user_id") // 顶级上级用户ID
// 推荐码
myReferralCode String @unique @map("my_referral_code") @db.VarChar(20)
usedReferralCode String? @map("used_referral_code") @db.VarChar(20)
// 推荐链 (使用PostgreSQL数组类型最多存储10层上级)
ancestorPath BigInt[] @map("ancestor_path") // [父节点, 祖父节点, ...] 从根到父的路径
depth Int @default(0) @map("depth") // 层级深度 (0=根节点)
// 直推统计 (快速查询用,冗余存储)
directReferralCount Int @default(0) @map("direct_referral_count")
activeDirectCount Int @default(0) @map("active_direct_count") // 已认种的直推人数
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 自引用关系 (方便查询推荐人)
referrer ReferralRelationship? @relation("ReferrerToReferral", fields: [referrerId], references: [userId])
directReferrals ReferralRelationship[] @relation("ReferrerToReferral")
// 关联团队统计
teamStatistics TeamStatistics?
@@map("referral_relationships")
@@index([referrerId], name: "idx_referrer")
@@index([myReferralCode], name: "idx_my_referral_code")
@@index([usedReferralCode], name: "idx_used_referral_code")
@@index([rootUserId], name: "idx_root_user")
@@index([depth], name: "idx_depth")
@@index([createdAt], name: "idx_referral_created")
}
// ============================================
// 团队统计表 (聚合根2)
// 每个用户的团队认种统计数据,需要实时更新
// ============================================
model TeamStatistics {
id BigInt @id @default(autoincrement()) @map("statistics_id")
userId BigInt @unique @map("user_id")
// === 注册统计 ===
directReferralCount Int @default(0) @map("direct_referral_count") // 直推注册数
totalTeamCount Int @default(0) @map("total_team_count") // 团队总注册数
// === 个人认种 ===
selfPlantingCount Int @default(0) @map("self_planting_count") // 自己认种数量
selfPlantingAmount Decimal @default(0) @map("self_planting_amount") @db.Decimal(20, 8)
// === 团队认种 (包含自己和所有下级) ===
directPlantingCount Int @default(0) @map("direct_planting_count") // 直推认种数
totalTeamPlantingCount Int @default(0) @map("total_team_planting_count") // 团队总认种数
totalTeamPlantingAmount Decimal @default(0) @map("total_team_planting_amount") @db.Decimal(20, 8)
// === 直推团队数据 (JSON存储每个直推的团队认种量) ===
// 格式: [{ userId: bigint, personalCount: int, teamCount: int, amount: decimal }, ...]
directTeamPlantingData Json @default("[]") @map("direct_team_planting_data")
// === 龙虎榜相关 ===
// 龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量
maxSingleTeamPlantingCount Int @default(0) @map("max_single_team_planting_count")
effectivePlantingCountForRanking Int @default(0) @map("effective_planting_count_for_ranking")
// === 本省本市统计 (用于省市授权考核) ===
ownProvinceTeamCount Int @default(0) @map("own_province_team_count") // 自有团队本省认种
ownCityTeamCount Int @default(0) @map("own_city_team_count") // 自有团队本市认种
provinceTeamPercentage Decimal @default(0) @map("province_team_percentage") @db.Decimal(5, 2) // 本省占比
cityTeamPercentage Decimal @default(0) @map("city_team_percentage") @db.Decimal(5, 2) // 本市占比
// === 省市分布 (JSON存储详细分布) ===
// 格式: { "provinceCode": { "cityCode": count, ... }, ... }
provinceCityDistribution Json @default("{}") @map("province_city_distribution")
// 时间戳
lastCalcAt DateTime? @map("last_calc_at") // 最后计算时间
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
referralRelationship ReferralRelationship @relation(fields: [userId], references: [userId])
@@map("team_statistics")
@@index([effectivePlantingCountForRanking(sort: Desc)], name: "idx_leaderboard_score")
@@index([totalTeamPlantingCount(sort: Desc)], name: "idx_team_planting")
@@index([selfPlantingCount], name: "idx_self_planting")
}
// ============================================
// 直推用户列表 (冗余表,便于分页查询)
// ============================================
model DirectReferral {
id BigInt @id @default(autoincrement()) @map("direct_referral_id")
referrerId BigInt @map("referrer_id") // 推荐人ID
referralId BigInt @map("referral_id") // 被推荐人ID
referralSequence BigInt @map("referral_sequence") // 被推荐人序列号
// 被推荐人信息快照 (冗余存储,避免跨服务查询)
referralNickname String? @map("referral_nickname") @db.VarChar(100)
referralAvatar String? @map("referral_avatar") @db.VarChar(255)
// 该直推的认种统计
personalPlantingCount Int @default(0) @map("personal_planting_count") // 个人认种数
teamPlantingCount Int @default(0) @map("team_planting_count") // 团队认种数(含个人)
// 是否已认种 (用于区分活跃/非活跃)
hasPlanted Boolean @default(false) @map("has_planted")
firstPlantedAt DateTime? @map("first_planted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([referrerId, referralId], name: "uk_referrer_referral")
@@map("direct_referrals")
@@index([referrerId], name: "idx_direct_referrer")
@@index([referralId], name: "idx_direct_referral")
@@index([hasPlanted], name: "idx_has_planted")
@@index([teamPlantingCount(sort: Desc)], name: "idx_direct_team_planting")
}
// ============================================
// 团队省市分布表 (用于省市权益分配)
// ============================================
model TeamProvinceCityDetail {
id BigInt @id @default(autoincrement()) @map("detail_id")
userId BigInt @map("user_id")
provinceCode String @map("province_code") @db.VarChar(10)
cityCode String @map("city_code") @db.VarChar(10)
teamPlantingCount Int @default(0) @map("team_planting_count") // 该省/市团队认种数
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, provinceCode, cityCode], name: "uk_user_province_city")
@@map("team_province_city_details")
@@index([userId], name: "idx_detail_user")
@@index([provinceCode], name: "idx_detail_province")
@@index([cityCode], name: "idx_detail_city")
}
// ============================================
// 推荐事件表 (行为表append-only用于审计和事件溯源)
// ============================================
model ReferralEvent {
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("referral_events")
@@index([aggregateType, aggregateId], name: "idx_event_aggregate")
@@index([eventType], name: "idx_event_type")
@@index([userId], name: "idx_event_user")
@@index([occurredAt], name: "idx_event_occurred")
}
```
### 2.2 初始化数据库
```bash
# 生成 Prisma Client
npx prisma generate
# 创建并运行迁移
npx prisma migrate dev --name init
# 查看数据库 (可选)
npx prisma studio
```
---
## 第三阶段:领域层实现 (Domain Layer)
### 3.1 值对象 (Value Objects)
#### 3.1.1 src/domain/value-objects/user-id.vo.ts
```typescript
export class UserId {
private constructor(public readonly value: bigint) {}
static create(value: bigint | string | number): UserId {
const bigIntValue = typeof value === 'bigint' ? value : BigInt(value);
if (bigIntValue <= 0n) {
throw new Error('用户ID必须大于0');
}
return new UserId(bigIntValue);
}
equals(other: UserId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value.toString();
}
}
```
#### 3.1.2 src/domain/value-objects/referral-code.vo.ts
```typescript
export class ReferralCode {
private constructor(public readonly value: string) {
if (!value || value.length < 6 || value.length > 20) {
throw new Error('推荐码长度必须在6-20个字符之间');
}
if (!/^[A-Z0-9]+$/.test(value)) {
throw new Error('推荐码只能包含大写字母和数字');
}
}
static create(value: string): ReferralCode {
return new ReferralCode(value.toUpperCase());
}
static generate(userId: bigint): ReferralCode {
// 生成规则: 前缀 + 用户ID哈希 + 随机字符
const prefix = 'RWA';
const userIdHash = userId.toString(36).toUpperCase().slice(-3).padStart(3, '0');
const random = Math.random().toString(36).toUpperCase().slice(2, 6);
return new ReferralCode(`${prefix}${userIdHash}${random}`);
}
equals(other: ReferralCode): boolean {
return this.value === other.value;
}
}
```
#### 3.1.3 src/domain/value-objects/referral-chain.vo.ts
```typescript
import { UserId } from './user-id.vo';
/**
* 推荐链值对象
* 存储从直接推荐人到根节点的用户ID列表
*/
export class ReferralChain {
private static readonly MAX_DEPTH = 10;
private constructor(
public readonly chain: bigint[], // [直接上级, 上上级, ...]
) {
if (chain.length > ReferralChain.MAX_DEPTH) {
throw new Error(`推荐链深度不能超过 ${ReferralChain.MAX_DEPTH}`);
}
}
static create(referrerId: bigint | null, parentChain: bigint[] = []): ReferralChain {
if (!referrerId) {
return new ReferralChain([]);
}
// 新链 = [直接推荐人] + 父链的前 MAX_DEPTH - 1 个元素
const newChain = [referrerId, ...parentChain.slice(0, this.MAX_DEPTH - 1)];
return new ReferralChain(newChain);
}
static fromArray(chain: bigint[]): ReferralChain {
return new ReferralChain([...chain]);
}
static empty(): ReferralChain {
return new ReferralChain([]);
}
get depth(): number {
return this.chain.length;
}
get directReferrer(): bigint | null {
return this.chain[0] ?? null;
}
/**
* 获取指定层级的推荐人
* @param level 层级 (0 = 直接推荐人, 1 = 推荐人的推荐人, ...)
*/
getReferrerAtLevel(level: number): bigint | null {
return this.chain[level] ?? null;
}
/**
* 获取所有上级 (用于团队统计更新)
*/
getAllAncestors(): bigint[] {
return [...this.chain];
}
toArray(): bigint[] {
return [...this.chain];
}
}
```
#### 3.1.4 src/domain/value-objects/leaderboard-score.vo.ts
```typescript
/**
* 龙虎榜分值值对象
*
* 计算公式: 团队总认种量 - 最大单个直推团队认种量
*
* 这个公式的设计目的:
* - 鼓励均衡发展团队,而不是只依赖单个大团队
* - 防止"烧伤"现象(单腿发展)
*/
export class LeaderboardScore {
private constructor(
public readonly totalTeamCount: number, // 团队总认种量
public readonly maxDirectTeamCount: number, // 最大直推团队认种量
public readonly score: number, // 龙虎榜分值
) {}
static calculate(
totalTeamCount: number,
directTeamCounts: number[], // 每个直推的团队认种量
): LeaderboardScore {
const maxDirectTeamCount = directTeamCounts.length > 0
? Math.max(...directTeamCounts)
: 0;
const score = Math.max(0, totalTeamCount - maxDirectTeamCount);
return new LeaderboardScore(totalTeamCount, maxDirectTeamCount, score);
}
static zero(): LeaderboardScore {
return new LeaderboardScore(0, 0, 0);
}
/**
* 当团队认种发生变化时重新计算
*/
recalculate(
newTotalTeamCount: number,
newDirectTeamCounts: number[],
): LeaderboardScore {
return LeaderboardScore.calculate(newTotalTeamCount, newDirectTeamCounts);
}
/**
* 比较排名 (降序排列)
*/
compareTo(other: LeaderboardScore): number {
return other.score - this.score;
}
}
```
#### 3.1.5 src/domain/value-objects/province-city-distribution.vo.ts
```typescript
export interface ProvinceCityCount {
provinceCode: string;
cityCode: string;
count: number;
}
/**
* 省市分布统计值对象
* 用于计算用户团队在各省/市的认种分布
*/
export class ProvinceCityDistribution {
private constructor(
private readonly distribution: Map<string, Map<string, number>>,
) {}
static empty(): ProvinceCityDistribution {
return new ProvinceCityDistribution(new Map());
}
static fromJson(json: Record<string, Record<string, number>> | null): ProvinceCityDistribution {
if (!json) {
return ProvinceCityDistribution.empty();
}
const map = new Map<string, Map<string, number>>();
for (const [province, cities] of Object.entries(json)) {
const cityMap = new Map<string, number>();
for (const [city, count] of Object.entries(cities)) {
cityMap.set(city, count);
}
map.set(province, cityMap);
}
return new ProvinceCityDistribution(map);
}
/**
* 添加认种记录 (返回新实例,不可变)
*/
add(provinceCode: string, cityCode: string, count: number): ProvinceCityDistribution {
const newDist = new Map(this.distribution);
if (!newDist.has(provinceCode)) {
newDist.set(provinceCode, new Map());
}
const cityMap = new Map(newDist.get(provinceCode)!);
cityMap.set(cityCode, (cityMap.get(cityCode) ?? 0) + count);
newDist.set(provinceCode, cityMap);
return new ProvinceCityDistribution(newDist);
}
/**
* 获取某省的总认种量
*/
getProvinceTotal(provinceCode: string): number {
const cities = this.distribution.get(provinceCode);
if (!cities) return 0;
let total = 0;
for (const count of cities.values()) {
total += count;
}
return total;
}
/**
* 获取某市的总认种量
*/
getCityTotal(provinceCode: string, cityCode: string): number {
return this.distribution.get(provinceCode)?.get(cityCode) ?? 0;
}
/**
* 获取所有省市的统计
*/
getAll(): ProvinceCityCount[] {
const result: ProvinceCityCount[] = [];
for (const [provinceCode, cities] of this.distribution) {
for (const [cityCode, count] of cities) {
result.push({ provinceCode, cityCode, count });
}
}
return result;
}
/**
* 获取总数
*/
getTotal(): number {
let total = 0;
for (const cities of this.distribution.values()) {
for (const count of cities.values()) {
total += count;
}
}
return total;
}
toJson(): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const [province, cities] of this.distribution) {
result[province] = {};
for (const [city, count] of cities) {
result[province][city] = count;
}
}
return result;
}
}
```
#### 3.1.6 src/domain/value-objects/index.ts
```typescript
export * from './user-id.vo';
export * from './referral-code.vo';
export * from './referral-chain.vo';
export * from './leaderboard-score.vo';
export * from './province-city-distribution.vo';
```
### 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/referral-relationship-created.event.ts
```typescript
import { DomainEvent } from './domain-event.base';
export interface ReferralRelationshipCreatedPayload {
userId: string;
referrerId: string | null;
myReferralCode: string;
usedReferralCode: string | null;
depth: number;
ancestorPath: string[];
}
export class ReferralRelationshipCreatedEvent extends DomainEvent {
constructor(private readonly payload: ReferralRelationshipCreatedPayload) {
super();
}
get eventType(): string {
return 'ReferralRelationshipCreated';
}
get aggregateId(): string {
return this.payload.userId;
}
get aggregateType(): string {
return 'ReferralRelationship';
}
toPayload(): ReferralRelationshipCreatedPayload {
return { ...this.payload };
}
}
```
#### 3.2.3 src/domain/events/team-statistics-updated.event.ts
```typescript
import { DomainEvent } from './domain-event.base';
export interface TeamStatisticsUpdatedPayload {
userId: string;
selfPlantingCount: number;
teamPlantingCount: number;
leaderboardScore: number;
triggeredByUserId?: string; // 触发更新的用户ID
}
export class TeamStatisticsUpdatedEvent extends DomainEvent {
constructor(private readonly payload: TeamStatisticsUpdatedPayload) {
super();
}
get eventType(): string {
return 'TeamStatisticsUpdated';
}
get aggregateId(): string {
return this.payload.userId;
}
get aggregateType(): string {
return 'TeamStatistics';
}
toPayload(): TeamStatisticsUpdatedPayload {
return { ...this.payload };
}
}
```
#### 3.2.4 src/domain/events/index.ts
```typescript
export * from './domain-event.base';
export * from './referral-relationship-created.event';
export * from './team-statistics-updated.event';
```
### 3.3 聚合根 (Aggregates)
#### 3.3.1 src/domain/aggregates/referral-relationship/referral-relationship.aggregate.ts
```typescript
import { ReferralCode } from '../../value-objects/referral-code.vo';
import { ReferralChain } from '../../value-objects/referral-chain.vo';
import { UserId } from '../../value-objects/user-id.vo';
import { DomainEvent } from '../../events/domain-event.base';
import { ReferralRelationshipCreatedEvent } from '../../events/referral-relationship-created.event';
/**
* 推荐关系聚合根
*
* 不变式:
* 1. 推荐关系一旦建立,终生不可修改
* 2. 不能推荐自己
* 3. 祖先路径必须完整 (从根节点到当前节点的所有上级)
*/
export class ReferralRelationship {
private _id: bigint | null = null;
private readonly _userId: UserId;
private readonly _myReferralCode: ReferralCode;
private readonly _usedReferralCode: ReferralCode | null;
private readonly _referrerId: UserId | null;
private readonly _rootUserId: UserId | null;
private readonly _referralChain: ReferralChain;
private _directReferralCount: number;
private _activeDirectCount: number;
private readonly _createdAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(
userId: UserId,
myReferralCode: ReferralCode,
usedReferralCode: ReferralCode | null,
referrerId: UserId | null,
rootUserId: UserId | null,
referralChain: ReferralChain,
) {
this._userId = userId;
this._myReferralCode = myReferralCode;
this._usedReferralCode = usedReferralCode;
this._referrerId = referrerId;
this._rootUserId = rootUserId;
this._referralChain = referralChain;
this._directReferralCount = 0;
this._activeDirectCount = 0;
this._createdAt = new Date();
}
// ============ Getters ============
get id(): bigint | null { return this._id; }
get userId(): UserId { return this._userId; }
get myReferralCode(): ReferralCode { return this._myReferralCode; }
get usedReferralCode(): ReferralCode | null { return this._usedReferralCode; }
get referrerId(): UserId | null { return this._referrerId; }
get rootUserId(): UserId | null { return this._rootUserId; }
get referralChain(): ReferralChain { return this._referralChain; }
get depth(): number { return this._referralChain.depth; }
get directReferralCount(): number { return this._directReferralCount; }
get activeDirectCount(): number { return this._activeDirectCount; }
get createdAt(): Date { return this._createdAt; }
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
// ============ 工厂方法 ============
/**
* 创建推荐关系 (有推荐人)
*/
static createWithReferrer(
userId: bigint,
referrer: ReferralRelationship,
usedReferralCode: string,
): ReferralRelationship {
const userIdVo = UserId.create(userId);
// 验证不能推荐自己
if (userIdVo.equals(referrer.userId)) {
throw new Error('不能推荐自己');
}
const myCode = ReferralCode.generate(userId);
const usedCode = ReferralCode.create(usedReferralCode);
// 构建推荐链
const referralChain = ReferralChain.create(
referrer.userId.value,
referrer.referralChain.toArray(),
);
// 确定根节点
const rootUserId = referrer.rootUserId || referrer.userId;
const relationship = new ReferralRelationship(
userIdVo,
myCode,
usedCode,
referrer.userId,
rootUserId,
referralChain,
);
// 发布领域事件
relationship._domainEvents.push(new ReferralRelationshipCreatedEvent({
userId: userId.toString(),
referrerId: referrer.userId.toString(),
myReferralCode: myCode.value,
usedReferralCode: usedCode.value,
depth: referralChain.depth,
ancestorPath: referralChain.toArray().map(id => id.toString()),
}));
return relationship;
}
/**
* 创建推荐关系 (无推荐人,根节点)
*/
static createRoot(userId: bigint): ReferralRelationship {
const userIdVo = UserId.create(userId);
const myCode = ReferralCode.generate(userId);
const relationship = new ReferralRelationship(
userIdVo,
myCode,
null,
null,
null,
ReferralChain.empty(),
);
// 发布领域事件
relationship._domainEvents.push(new ReferralRelationshipCreatedEvent({
userId: userId.toString(),
referrerId: null,
myReferralCode: myCode.value,
usedReferralCode: null,
depth: 0,
ancestorPath: [],
}));
return relationship;
}
// ============ 领域行为 ============
/**
* 增加直推人数
*/
incrementDirectReferralCount(): void {
this._directReferralCount++;
}
/**
* 直推用户认种后,更新活跃直推数
*/
markDirectAsActive(): void {
this._activeDirectCount++;
}
/**
* 获取所有上级用户ID (用于更新团队统计)
*/
getAllAncestorIds(): bigint[] {
return this._referralChain.getAllAncestors();
}
/**
* 获取直接推荐人ID
*/
getDirectReferrerId(): bigint | null {
return this._referrerId?.value ?? null;
}
/**
* 设置ID (用于从数据库重建)
*/
setId(id: bigint): void {
this._id = id;
}
clearDomainEvents(): void {
this._domainEvents = [];
}
// ============ 重建 ============
/**
* 从数据库重建聚合
*/
static reconstitute(data: {
id: bigint;
userId: bigint;
myReferralCode: string;
usedReferralCode: string | null;
referrerId: bigint | null;
rootUserId: bigint | null;
ancestorPath: bigint[];
directReferralCount: number;
activeDirectCount: number;
createdAt: Date;
}): ReferralRelationship {
const relationship = new ReferralRelationship(
UserId.create(data.userId),
ReferralCode.create(data.myReferralCode),
data.usedReferralCode ? ReferralCode.create(data.usedReferralCode) : null,
data.referrerId ? UserId.create(data.referrerId) : null,
data.rootUserId ? UserId.create(data.rootUserId) : null,
ReferralChain.fromArray(data.ancestorPath),
);
relationship._id = data.id;
relationship._directReferralCount = data.directReferralCount;
relationship._activeDirectCount = data.activeDirectCount;
return relationship;
}
}
```
#### 3.3.2 src/domain/aggregates/team-statistics/team-statistics.aggregate.ts
```typescript
import { UserId } from '../../value-objects/user-id.vo';
import { LeaderboardScore } from '../../value-objects/leaderboard-score.vo';
import { ProvinceCityDistribution } from '../../value-objects/province-city-distribution.vo';
import { DomainEvent } from '../../events/domain-event.base';
import { TeamStatisticsUpdatedEvent } from '../../events/team-statistics-updated.event';
interface DirectTeamData {
userId: bigint;
personalCount: number;
teamCount: number;
amount: number;
}
/**
* 团队统计聚合根
*
* 不变式:
* 1. 团队统计必须在认种后实时更新
* 2. 龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量
*/
export class TeamStatistics {
private _id: bigint | null = null;
private readonly _userId: UserId;
// 注册统计
private _directReferralCount: number;
private _totalTeamCount: number;
// 个人认种
private _selfPlantingCount: number;
private _selfPlantingAmount: number;
// 团队认种
private _directPlantingCount: number;
private _totalTeamPlantingCount: number;
private _totalTeamPlantingAmount: number;
// 直推团队数据
private _directTeamPlantingData: DirectTeamData[];
// 龙虎榜
private _leaderboardScore: LeaderboardScore;
// 本省本市统计
private _ownProvinceTeamCount: number;
private _ownCityTeamCount: number;
private _provinceTeamPercentage: number;
private _cityTeamPercentage: number;
// 省市分布
private _provinceCityDistribution: ProvinceCityDistribution;
private _lastCalcAt: Date | null;
private readonly _createdAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(userId: UserId) {
this._userId = userId;
this._directReferralCount = 0;
this._totalTeamCount = 0;
this._selfPlantingCount = 0;
this._selfPlantingAmount = 0;
this._directPlantingCount = 0;
this._totalTeamPlantingCount = 0;
this._totalTeamPlantingAmount = 0;
this._directTeamPlantingData = [];
this._leaderboardScore = LeaderboardScore.zero();
this._ownProvinceTeamCount = 0;
this._ownCityTeamCount = 0;
this._provinceTeamPercentage = 0;
this._cityTeamPercentage = 0;
this._provinceCityDistribution = ProvinceCityDistribution.empty();
this._lastCalcAt = null;
this._createdAt = new Date();
}
// ============ Getters ============
get id(): bigint | null { return this._id; }
get userId(): UserId { return this._userId; }
get directReferralCount(): number { return this._directReferralCount; }
get totalTeamCount(): number { return this._totalTeamCount; }
get selfPlantingCount(): number { return this._selfPlantingCount; }
get selfPlantingAmount(): number { return this._selfPlantingAmount; }
get directPlantingCount(): number { return this._directPlantingCount; }
get totalTeamPlantingCount(): number { return this._totalTeamPlantingCount; }
get totalTeamPlantingAmount(): number { return this._totalTeamPlantingAmount; }
get directTeamPlantingData(): ReadonlyArray<DirectTeamData> { return this._directTeamPlantingData; }
get leaderboardScore(): LeaderboardScore { return this._leaderboardScore; }
get maxSingleTeamPlantingCount(): number { return this._leaderboardScore.maxDirectTeamCount; }
get effectivePlantingCountForRanking(): number { return this._leaderboardScore.score; }
get ownProvinceTeamCount(): number { return this._ownProvinceTeamCount; }
get ownCityTeamCount(): number { return this._ownCityTeamCount; }
get provinceTeamPercentage(): number { return this._provinceTeamPercentage; }
get cityTeamPercentage(): number { return this._cityTeamPercentage; }
get provinceCityDistribution(): ProvinceCityDistribution { return this._provinceCityDistribution; }
get lastCalcAt(): Date | null { return this._lastCalcAt; }
get createdAt(): Date { return this._createdAt; }
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
// ============ 工厂方法 ============
static create(userId: bigint): TeamStatistics {
return new TeamStatistics(UserId.create(userId));
}
// ============ 领域行为 ============
/**
* 增加直推注册
*/
addDirectReferral(referralUserId: bigint): void {
this._directReferralCount++;
this._totalTeamCount++;
// 初始化直推团队数据
if (!this._directTeamPlantingData.find(d => d.userId === referralUserId)) {
this._directTeamPlantingData.push({
userId: referralUserId,
personalCount: 0,
teamCount: 0,
amount: 0,
});
}
this._lastCalcAt = new Date();
}
/**
* 增加团队成员 (非直推)
*/
addTeamMember(): void {
this._totalTeamCount++;
this._lastCalcAt = new Date();
}
/**
* 记录自己的认种
*/
addSelfPlanting(
treeCount: number,
amount: number,
provinceCode: string,
cityCode: string,
userProvince?: string,
userCity?: string,
): void {
this._selfPlantingCount += treeCount;
this._selfPlantingAmount += amount;
// 自己的认种也计入团队
this._totalTeamPlantingCount += treeCount;
this._totalTeamPlantingAmount += amount;
// 更新省市分布
this._provinceCityDistribution = this._provinceCityDistribution.add(
provinceCode,
cityCode,
treeCount,
);
// 更新本省本市统计
if (userProvince && provinceCode === userProvince) {
this._ownProvinceTeamCount += treeCount;
}
if (userCity && cityCode === userCity) {
this._ownCityTeamCount += treeCount;
}
// 重新计算龙虎榜分值和占比
this._recalculateLeaderboardScore();
this._recalculatePercentages();
this._lastCalcAt = new Date();
this._domainEvents.push(new TeamStatisticsUpdatedEvent({
userId: this._userId.toString(),
selfPlantingCount: this._selfPlantingCount,
teamPlantingCount: this._totalTeamPlantingCount,
leaderboardScore: this._leaderboardScore.score,
}));
}
/**
* 更新团队认种数据 (下级用户认种时调用)
*/
updatePlanting(params: {
isDirectReferral: boolean;
directReferralId?: bigint;
treeCount: number;
amount: number;
province: string;
city: string;
userProvince?: string;
userCity?: string;
}): void {
// 1. 更新团队总认种
this._totalTeamPlantingCount += params.treeCount;
this._totalTeamPlantingAmount += params.amount;
// 2. 如果是直推
if (params.isDirectReferral && params.directReferralId) {
this._directPlantingCount += params.treeCount;
// 更新直推明细
const directTeam = this._directTeamPlantingData.find(
d => d.userId === params.directReferralId
);
if (directTeam) {
directTeam.personalCount += params.treeCount;
directTeam.teamCount += params.treeCount;
directTeam.amount += params.amount;
}
} else if (params.directReferralId) {
// 非直推但需要更新直推的团队数据
const directTeam = this._directTeamPlantingData.find(
d => d.userId === params.directReferralId
);
if (directTeam) {
directTeam.teamCount += params.treeCount;
directTeam.amount += params.amount;
}
}
// 3. 更新省市分布
this._provinceCityDistribution = this._provinceCityDistribution.add(
params.province,
params.city,
params.treeCount,
);
// 4. 更新本省本市统计
if (params.userProvince && params.province === params.userProvince) {
this._ownProvinceTeamCount += params.treeCount;
}
if (params.userCity && params.city === params.userCity) {
this._ownCityTeamCount += params.treeCount;
}
// 5. 重新计算龙虎榜分值和占比
this._recalculateLeaderboardScore();
this._recalculatePercentages();
this._lastCalcAt = new Date();
this._domainEvents.push(new TeamStatisticsUpdatedEvent({
userId: this._userId.toString(),
selfPlantingCount: this._selfPlantingCount,
teamPlantingCount: this._totalTeamPlantingCount,
leaderboardScore: this._leaderboardScore.score,
triggeredByUserId: params.directReferralId?.toString(),
}));
}
/**
* 更新直推团队认种数
*/
updateDirectReferralTeamPlanting(
directReferralId: bigint,
teamPlantingCount: number,
): void {
const directTeam = this._directTeamPlantingData.find(
d => d.userId === directReferralId
);
if (directTeam) {
directTeam.teamCount = teamPlantingCount;
}
this._recalculateLeaderboardScore();
this._lastCalcAt = new Date();
}
// ============ 私有方法 ============
private _recalculateLeaderboardScore(): void {
const directCounts = this._directTeamPlantingData.map(d => d.teamCount);
this._leaderboardScore = LeaderboardScore.calculate(
this._totalTeamPlantingCount,
directCounts,
);
}
private _recalculatePercentages(): void {
if (this._totalTeamPlantingCount > 0) {
this._provinceTeamPercentage =
(this._ownProvinceTeamCount / this._totalTeamPlantingCount) * 100;
this._cityTeamPercentage =
(this._ownCityTeamCount / this._totalTeamPlantingCount) * 100;
} else {
this._provinceTeamPercentage = 0;
this._cityTeamPercentage = 0;
}
}
setId(id: bigint): void {
this._id = id;
}
clearDomainEvents(): void {
this._domainEvents = [];
}
// ============ 重建 ============
static reconstitute(data: {
id: bigint;
userId: bigint;
directReferralCount: number;
totalTeamCount: number;
selfPlantingCount: number;
selfPlantingAmount: number;
directPlantingCount: number;
totalTeamPlantingCount: number;
totalTeamPlantingAmount: number;
directTeamPlantingData: DirectTeamData[];
ownProvinceTeamCount: number;
ownCityTeamCount: number;
provinceCityDistribution: Record<string, Record<string, number>> | null;
lastCalcAt: Date | null;
createdAt: Date;
}): TeamStatistics {
const stats = new TeamStatistics(UserId.create(data.userId));
stats._id = data.id;
stats._directReferralCount = data.directReferralCount;
stats._totalTeamCount = data.totalTeamCount;
stats._selfPlantingCount = data.selfPlantingCount;
stats._selfPlantingAmount = data.selfPlantingAmount;
stats._directPlantingCount = data.directPlantingCount;
stats._totalTeamPlantingCount = data.totalTeamPlantingCount;
stats._totalTeamPlantingAmount = data.totalTeamPlantingAmount;
stats._directTeamPlantingData = data.directTeamPlantingData || [];
stats._ownProvinceTeamCount = data.ownProvinceTeamCount;
stats._ownCityTeamCount = data.ownCityTeamCount;
stats._provinceCityDistribution = ProvinceCityDistribution.fromJson(
data.provinceCityDistribution
);
stats._lastCalcAt = data.lastCalcAt;
// 重新计算龙虎榜分值和占比
stats._recalculateLeaderboardScore();
stats._recalculatePercentages();
return stats;
}
}
```
### 3.4 仓储接口 (Repository Interfaces)
#### 3.4.1 src/domain/repositories/referral-relationship.repository.interface.ts
```typescript
import { ReferralRelationship } from '../aggregates/referral-relationship/referral-relationship.aggregate';
export interface IReferralRelationshipRepository {
save(relationship: ReferralRelationship): Promise<void>;
findById(id: bigint): Promise<ReferralRelationship | null>;
findByUserId(userId: bigint): Promise<ReferralRelationship | null>;
findByReferralCode(code: string): Promise<ReferralRelationship | null>;
findDirectReferrals(userId: bigint, page?: number, pageSize?: number): Promise<ReferralRelationship[]>;
countDirectReferrals(userId: bigint): Promise<number>;
findByReferrerId(referrerId: bigint): Promise<ReferralRelationship[]>;
findByAncestor(ancestorId: bigint): Promise<ReferralRelationship[]>;
}
export const REFERRAL_RELATIONSHIP_REPOSITORY = Symbol('IReferralRelationshipRepository');
```
#### 3.4.2 src/domain/repositories/team-statistics.repository.interface.ts
```typescript
import { TeamStatistics } from '../aggregates/team-statistics/team-statistics.aggregate';
export interface ITeamStatisticsRepository {
save(stats: TeamStatistics): Promise<void>;
findByUserId(userId: bigint): Promise<TeamStatistics | null>;
getOrCreate(userId: bigint): Promise<TeamStatistics>;
findTopByLeaderboardScore(limit: number): Promise<TeamStatistics[]>;
findByProvinceCityRanking(
provinceCode: string,
cityCode: string | null,
limit: number,
): Promise<TeamStatistics[]>;
findByUserIds(userIds: bigint[]): Promise<Map<string, TeamStatistics>>;
}
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository');
```
#### 3.4.3 src/domain/repositories/index.ts
```typescript
export * from './referral-relationship.repository.interface';
export * from './team-statistics.repository.interface';
```
### 3.5 领域服务 (Domain Services)
#### 3.5.1 src/domain/services/referral-chain.service.ts
```typescript
import { Injectable } from '@nestjs/common';
import { ReferralRelationship } from '../aggregates/referral-relationship/referral-relationship.aggregate';
@Injectable()
export class ReferralChainService {
/**
* 获取用户的推荐链 (用于分享权益分配)
* 返回从直接推荐人到最远祖先的用户ID列表
*/
getReferralChain(relationship: ReferralRelationship): bigint[] {
return relationship.getAllAncestorIds();
}
/**
* 查找两个用户的最近公共祖先
*/
findCommonAncestor(
chain1: bigint[],
chain2: bigint[],
): bigint | null {
const set1 = new Set(chain1.map(id => id.toString()));
for (const id of chain2) {
if (set1.has(id.toString())) {
return id;
}
}
return null;
}
/**
* 计算两个用户之间的推荐距离
*/
calculateDistance(
relationship1: ReferralRelationship,
relationship2: ReferralRelationship,
): number | null {
const chain1 = [relationship1.userId.value, ...relationship1.getAllAncestorIds()];
const chain2 = [relationship2.userId.value, ...relationship2.getAllAncestorIds()];
// 检查 user2 是否在 user1 的链上
const idx1 = chain1.findIndex(id => id === relationship2.userId.value);
if (idx1 >= 0) return idx1;
// 检查 user1 是否在 user2 的链上
const idx2 = chain2.findIndex(id => id === relationship1.userId.value);
if (idx2 >= 0) return idx2;
// 寻找公共祖先
for (let i = 0; i < chain1.length; i++) {
const idx = chain2.findIndex(id => id === chain1[i]);
if (idx >= 0) {
return i + idx;
}
}
return null; // 不在同一棵树上
}
}
```
#### 3.5.2 src/domain/services/leaderboard-calculator.service.ts
```typescript
import { Injectable } from '@nestjs/common';
import { TeamStatistics } from '../aggregates/team-statistics/team-statistics.aggregate';
export interface LeaderboardEntry {
userId: bigint;
score: number;
teamPlantingCount: number;
maxDirectTeamCount: number;
rank: number;
}
@Injectable()
export class LeaderboardCalculatorService {
/**
* 计算龙虎榜排名
*
* 龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量
*/
calculateLeaderboard(
allStats: TeamStatistics[],
limit = 100,
): LeaderboardEntry[] {
// 按龙虎榜分值排序
const sorted = allStats
.filter(s => s.effectivePlantingCountForRanking > 0)
.sort((a, b) => b.effectivePlantingCountForRanking - a.effectivePlantingCountForRanking);
// 取前 limit 名并添加排名
return sorted.slice(0, limit).map((stats, index) => ({
userId: stats.userId.value,
score: stats.effectivePlantingCountForRanking,
teamPlantingCount: stats.totalTeamPlantingCount,
maxDirectTeamCount: stats.maxSingleTeamPlantingCount,
rank: index + 1,
}));
}
/**
* 获取用户在龙虎榜中的排名
*/
getUserRank(
allStats: TeamStatistics[],
userId: bigint,
): number | null {
const sorted = allStats
.filter(s => s.effectivePlantingCountForRanking > 0)
.sort((a, b) => b.effectivePlantingCountForRanking - a.effectivePlantingCountForRanking);
const index = sorted.findIndex(s => s.userId.value === userId);
return index >= 0 ? index + 1 : null;
}
}
```
#### 3.5.3 src/domain/services/team-aggregation.service.ts
```typescript
import { Injectable, Inject } from '@nestjs/common';
import {
ITeamStatisticsRepository,
TEAM_STATISTICS_REPOSITORY
} from '../repositories/team-statistics.repository.interface';
import {
IReferralRelationshipRepository,
REFERRAL_RELATIONSHIP_REPOSITORY
} from '../repositories/referral-relationship.repository.interface';
import { TeamStatistics } from '../aggregates/team-statistics/team-statistics.aggregate';
@Injectable()
export class TeamAggregationService {
constructor(
@Inject(TEAM_STATISTICS_REPOSITORY)
private readonly statsRepository: ITeamStatisticsRepository,
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
private readonly relationshipRepository: IReferralRelationshipRepository,
) {}
/**
* 当用户认种时,更新所有上级的团队统计
*/
async updateAncestorTeamStats(
userId: bigint,
treeCount: number,
amount: number,
provinceCode: string,
cityCode: string,
userProvince?: string,
userCity?: string,
): Promise<void> {
// 1. 获取用户的推荐关系
const relationship = await this.relationshipRepository.findByUserId(userId);
if (!relationship) {
throw new Error(`用户 ${userId} 的推荐关系不存在`);
}
// 2. 更新自己的统计
const selfStats = await this.statsRepository.getOrCreate(userId);
selfStats.addSelfPlanting(treeCount, amount, provinceCode, cityCode, userProvince, userCity);
await this.statsRepository.save(selfStats);
// 3. 获取推荐链 (所有上级)
const ancestors = relationship.getAllAncestorIds();
if (ancestors.length === 0) return;
// 4. 找到直接推荐人 (第一层上级)
const directReferrerId = ancestors[0];
// 5. 更新所有上级的团队统计
for (let i = 0; i < ancestors.length; i++) {
const ancestorId = ancestors[i];
const ancestorStats = await this.statsRepository.getOrCreate(ancestorId);
// 对于每个祖先,确定这个认种是通过哪个直推传递来的
const directUserIdForAncestor = i === 0 ? userId : ancestors[i - 1];
ancestorStats.updatePlanting({
isDirectReferral: i === 0, // 只有第一层是直推
directReferralId: directUserIdForAncestor,
treeCount,
amount,
province: provinceCode,
city: cityCode,
userProvince,
userCity,
});
await this.statsRepository.save(ancestorStats);
}
}
/**
* 当新用户注册时,更新所有上级的团队人数
*/
async updateAncestorTeamMemberCount(
userId: bigint,
directReferrerId: bigint,
): Promise<void> {
// 1. 更新直接推荐人
const directReferrerStats = await this.statsRepository.getOrCreate(directReferrerId);
directReferrerStats.addDirectReferral(userId);
await this.statsRepository.save(directReferrerStats);
// 2. 获取直接推荐人的推荐关系
const directReferrerRelationship = await this.relationshipRepository.findByUserId(directReferrerId);
if (!directReferrerRelationship) return;
// 3. 更新所有上级的团队人数
const ancestors = directReferrerRelationship.getAllAncestorIds();
for (const ancestorId of ancestors) {
const ancestorStats = await this.statsRepository.getOrCreate(ancestorId);
ancestorStats.addTeamMember();
await this.statsRepository.save(ancestorStats);
}
}
}
```
#### 3.5.4 src/domain/services/index.ts
```typescript
export * from './referral-chain.service';
export * from './leaderboard-calculator.service';
export * from './team-aggregation.service';
```
---
## 领域不变式 (Domain Invariants)
```typescript
// 文档参考,不需要创建此文件
class ReferralContextInvariants {
// 1. 推荐关系不可修改
static REFERRAL_RELATIONSHIP_IMMUTABLE =
"推荐关系一旦建立,终生不可修改"
// 2. 祖先路径必须完整
static ANCESTOR_PATH_COMPLETE =
"祖先路径必须包含从根节点到当前节点的所有上级"
// 3. 不能推荐自己
static CANNOT_REFER_SELF =
"用户不能推荐自己"
// 4. 团队统计必须实时更新
static TEAM_STATS_REALTIME =
"团队统计数据必须在认种后实时更新"
// 5. 龙虎榜分值计算规则
static LEADERBOARD_SCORE_RULE =
"龙虎榜分值 = 团队总认种量 - 最大单个直推团队认种量"
// 6. 推荐链最多10层
static MAX_REFERRAL_DEPTH =
"推荐链深度限制为10层超过的不计入"
}
```
---
## API 端点设计
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| GET | `/health` | 健康检查 | 否 |
| GET | `/referrals/me` | 获取我的推荐信息 (分享页) | JWT |
| GET | `/referrals/direct` | 获取我的直推列表 | JWT |
| GET | `/referrals/chain` | 获取推荐链 (内部API用于资金分配) | JWT |
| GET | `/team/statistics` | 获取我的团队统计 | JWT |
| GET | `/team/province-city-ranking` | 获取省/市团队排名 | JWT |
| GET | `/leaderboard` | 获取龙虎榜 | JWT |
| POST | `/internal/referral` | 创建推荐关系 (内部调用) | API-KEY |
---
## 与前端页面对应关系
### 向导页5 (分享引导页)
- 显示用户的推荐码
- 显示分享链接/二维码
- 调用: `GET /referrals/me`
### 分享页
- 显示我的推荐码和分享链接
- 显示直推人数、团队人数
- 显示团队认种统计
- 调用: `GET /referrals/me`
- 调用: `GET /team/statistics`
### 直推列表
- 显示我的直推用户列表
- 显示每个直推的认种情况
- 调用: `GET /referrals/direct?page=1&pageSize=20`
### 龙虎榜
- 显示全平台龙虎榜排名
- 调用: `GET /leaderboard?limit=100`
---
## 事件订阅 (Kafka Events)
### 订阅的事件
| Topic | 事件类型 | 触发条件 | 处理逻辑 |
|-------|---------|---------|---------|
| `identity.user.created` | UserAccountCreatedEvent | 用户注册成功 | 创建推荐关系,更新上级团队人数 |
| `planting.order.paid` | PlantingOrderPaidEvent | 认种订单支付成功 | 更新认种用户及所有上级的团队统计 |
### 发布的事件
| Topic | 事件类型 | 触发条件 |
|-------|---------|---------|
| `referral.relationship.created` | ReferralRelationshipCreatedEvent | 推荐关系创建成功 |
| `referral.statistics.updated` | TeamStatisticsUpdatedEvent | 团队统计更新 |
---
## 开发顺序建议
1. **Phase 1: 项目初始化**
- 创建NestJS项目
- 安装依赖
- 配置环境变量和TypeScript
2. **Phase 2: 数据库层**
- 创建Prisma Schema
- 运行迁移
- 创建PrismaService
3. **Phase 3: 领域层 (最重要)**
- 实现所有值对象
- 实现聚合根 (ReferralRelationship, TeamStatistics)
- 实现领域事件
- 实现领域服务
- 编写单元测试
4. **Phase 4: 基础设施层**
- 实现仓储 (Repository Implementations)
- 实现Kafka消费者和发布者
- 实现Redis缓存服务
- 实现外部服务客户端
5. **Phase 5: 应用层**
- 实现应用服务 (ReferralApplicationService)
- 实现Command/Query handlers
6. **Phase 6: API层**
- 实现DTO
- 实现Controllers
- 配置Swagger文档
- 配置JWT认证
7. **Phase 7: 测试和部署**
- 集成测试
- E2E测试
- Docker配置
- CI/CD配置
---
## 注意事项
1. **推荐关系在用户注册时创建**:由 identity-service 发布 `UserAccountCreatedEvent`,本服务订阅并创建推荐关系
2. **团队统计在认种时更新**:由 planting-service 发布 `PlantingOrderPaidEvent`,本服务订阅并更新所有上级的统计
3. **龙虎榜分值设计目的**:鼓励均衡发展团队,防止"单腿"发展
4. **省市权益分配**:根据省市团队占比计算,用于省/市代理权益分配
5. **使用 PostgreSQL 数组类型**:存储推荐链,方便查询
6. **直推团队数据使用 JSON 类型**:便于灵活扩展
7. **保持与 identity-service 架构一致**:确保代码风格、目录结构、命名规范统一