61 KiB
61 KiB
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 项目
cd backend/services/referral-service
npx @nestjs/cli new . --skip-git --package-manager npm
1.2 安装依赖
# 核心依赖
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 配置
{
"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 配置
{
"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:
# 应用配置
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
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 初始化数据库
# 生成 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
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
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
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
/**
* 龙虎榜分值值对象
*
* 计算公式: 团队总认种量 - 最大单个直推团队认种量
*
* 这个公式的设计目的:
* - 鼓励均衡发展团队,而不是只依赖单个大团队
* - 防止"烧伤"现象(单腿发展)
*/
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
export * from './referral-chain.service';
export * from './leaderboard-calculator.service';
export * from './team-aggregation.service';
领域不变式 (Domain Invariants)
// 文档参考,不需要创建此文件
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 | 团队统计更新 |
开发顺序建议
-
Phase 1: 项目初始化
- 创建NestJS项目
- 安装依赖
- 配置环境变量和TypeScript
-
Phase 2: 数据库层
- 创建Prisma Schema
- 运行迁移
- 创建PrismaService
-
Phase 3: 领域层 (最重要)
- 实现所有值对象
- 实现聚合根 (ReferralRelationship, TeamStatistics)
- 实现领域事件
- 实现领域服务
- 编写单元测试
-
Phase 4: 基础设施层
- 实现仓储 (Repository Implementations)
- 实现Kafka消费者和发布者
- 实现Redis缓存服务
- 实现外部服务客户端
-
Phase 5: 应用层
- 实现应用服务 (ReferralApplicationService)
- 实现Command/Query handlers
-
Phase 6: API层
- 实现DTO
- 实现Controllers
- 配置Swagger文档
- 配置JWT认证
-
Phase 7: 测试和部署
- 集成测试
- E2E测试
- Docker配置
- CI/CD配置
注意事项
-
推荐关系在用户注册时创建:由 identity-service 发布
UserAccountCreatedEvent,本服务订阅并创建推荐关系 -
团队统计在认种时更新:由 planting-service 发布
PlantingOrderPaidEvent,本服务订阅并更新所有上级的统计 -
龙虎榜分值设计目的:鼓励均衡发展团队,防止"单腿"发展
-
省市权益分配:根据省市团队占比计算,用于省/市代理权益分配
-
使用 PostgreSQL 数组类型:存储推荐链,方便查询
-
直推团队数据使用 JSON 类型:便于灵活扩展
-
保持与 identity-service 架构一致:确保代码风格、目录结构、命名规范统一