1717 lines
51 KiB
Markdown
1717 lines
51 KiB
Markdown
# Referral Service 开发指导
|
||
|
||
## 项目概述
|
||
|
||
Referral Service 是 RWA 榴莲女皇平台的推荐团队微服务,负责管理推荐关系树、团队统计维护、龙虎榜分值计算、省市团队占比统计等功能。
|
||
|
||
## 技术栈
|
||
|
||
- **框架**: NestJS
|
||
- **数据库**: PostgreSQL + Prisma ORM
|
||
- **架构**: DDD + Hexagonal Architecture (六边形架构)
|
||
- **语言**: TypeScript
|
||
|
||
## 架构参考
|
||
|
||
请参考 `identity-service` 的架构模式,保持一致性:
|
||
|
||
```
|
||
referral-service/
|
||
├── prisma/
|
||
│ └── schema.prisma # 数据库模型
|
||
├── src/
|
||
│ ├── api/ # Presentation Layer (API层)
|
||
│ │ ├── controllers/
|
||
│ │ │ ├── referral.controller.ts
|
||
│ │ │ ├── team.controller.ts
|
||
│ │ │ └── leaderboard.controller.ts
|
||
│ │ ├── dto/
|
||
│ │ │ ├── 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.command.ts
|
||
│ │ │ ├── update-team-statistics.command.ts
|
||
│ │ │ └── recalculate-leaderboard-score.command.ts
|
||
│ │ ├── queries/
|
||
│ │ │ ├── get-my-referral-info.query.ts
|
||
│ │ │ ├── get-direct-referrals.query.ts
|
||
│ │ │ ├── get-team-statistics.query.ts
|
||
│ │ │ ├── get-referral-chain.query.ts
|
||
│ │ │ ├── get-province-team-ranking.query.ts
|
||
│ │ │ └── get-leaderboard.query.ts
|
||
│ │ ├── handlers/
|
||
│ │ │ ├── create-referral-relationship.handler.ts
|
||
│ │ │ ├── update-team-statistics.handler.ts
|
||
│ │ │ └── on-planting-order-paid.handler.ts
|
||
│ │ └── services/
|
||
│ │ ├── referral-application.service.ts
|
||
│ │ └── team-statistics.service.ts
|
||
│ │
|
||
│ ├── domain/ # Domain Layer (领域层)
|
||
│ │ ├── aggregates/
|
||
│ │ │ ├── referral-relationship.aggregate.ts
|
||
│ │ │ └── team-statistics.aggregate.ts
|
||
│ │ ├── value-objects/
|
||
│ │ │ ├── referral-code.vo.ts
|
||
│ │ │ ├── referral-chain.vo.ts
|
||
│ │ │ ├── team-planting-count.vo.ts
|
||
│ │ │ ├── leaderboard-score.vo.ts
|
||
│ │ │ └── province-city-distribution.vo.ts
|
||
│ │ ├── events/
|
||
│ │ │ ├── referral-relationship-created.event.ts
|
||
│ │ │ ├── team-statistics-updated.event.ts
|
||
│ │ │ └── leaderboard-score-changed.event.ts
|
||
│ │ ├── repositories/
|
||
│ │ │ ├── referral-relationship.repository.interface.ts
|
||
│ │ │ └── team-statistics.repository.interface.ts
|
||
│ │ └── services/
|
||
│ │ ├── referral-chain.service.ts
|
||
│ │ ├── leaderboard-calculator.service.ts
|
||
│ │ └── team-aggregation.service.ts
|
||
│ │
|
||
│ ├── infrastructure/ # Infrastructure Layer (基础设施层)
|
||
│ │ ├── persistence/
|
||
│ │ │ ├── mappers/
|
||
│ │ │ │ ├── referral-relationship.mapper.ts
|
||
│ │ │ │ └── team-statistics.mapper.ts
|
||
│ │ │ └── repositories/
|
||
│ │ │ ├── referral-relationship.repository.impl.ts
|
||
│ │ │ └── team-statistics.repository.impl.ts
|
||
│ │ ├── external/
|
||
│ │ │ ├── identity-service.client.ts
|
||
│ │ │ └── planting-service.client.ts
|
||
│ │ ├── kafka/
|
||
│ │ │ ├── event-consumer.controller.ts
|
||
│ │ │ └── event-publisher.service.ts
|
||
│ │ └── infrastructure.module.ts
|
||
│ │
|
||
│ ├── app.module.ts
|
||
│ └── main.ts
|
||
├── .env.development
|
||
├── .env.example
|
||
├── package.json
|
||
└── tsconfig.json
|
||
```
|
||
|
||
---
|
||
|
||
## 第一阶段:项目初始化
|
||
|
||
### 1.1 创建 NestJS 项目
|
||
|
||
```bash
|
||
cd backend/services
|
||
npx @nestjs/cli new referral-service --skip-git --package-manager npm
|
||
cd referral-service
|
||
```
|
||
|
||
### 1.2 安装依赖
|
||
|
||
```bash
|
||
npm install @nestjs/config @prisma/client class-validator class-transformer uuid
|
||
npm install -D prisma @types/uuid
|
||
```
|
||
|
||
### 1.3 配置环境变量
|
||
|
||
创建 `.env.development`:
|
||
```env
|
||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_referral?schema=public"
|
||
NODE_ENV=development
|
||
PORT=3004
|
||
|
||
# 外部服务
|
||
IDENTITY_SERVICE_URL=http://localhost:3001
|
||
PLANTING_SERVICE_URL=http://localhost:3003
|
||
|
||
# Kafka
|
||
KAFKA_BROKERS=localhost:9092
|
||
KAFKA_GROUP_ID=referral-service-group
|
||
```
|
||
|
||
---
|
||
|
||
## 第二阶段:数据库设计 (Prisma Schema)
|
||
|
||
### 2.1 创建 prisma/schema.prisma
|
||
|
||
```prisma
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
// ============================================
|
||
// 推荐关系表 (状态表)
|
||
// 记录用户与推荐人的关系
|
||
// ============================================
|
||
model ReferralRelationship {
|
||
id BigInt @id @default(autoincrement()) @map("relationship_id")
|
||
userId BigInt @unique @map("user_id")
|
||
referrerId BigInt? @map("referrer_id") // 直接推荐人 (null = 系统推荐/无推荐人)
|
||
|
||
// 推荐码
|
||
myReferralCode String @unique @map("my_referral_code") @db.VarChar(20)
|
||
usedReferralCode String? @map("used_referral_code") @db.VarChar(20)
|
||
|
||
// 推荐链 (上级链,最多10层)
|
||
referralChain BigInt[] @map("referral_chain") // [直接上级, 上上级, ...]
|
||
depth Int @default(0) @map("depth") // 在推荐树中的深度
|
||
|
||
// 直推统计 (快速查询用)
|
||
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])
|
||
@@index([myReferralCode])
|
||
@@index([usedReferralCode])
|
||
@@index([depth])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
// ============================================
|
||
// 团队统计表 (状态表)
|
||
// 每个用户的团队认种统计数据
|
||
// ============================================
|
||
model TeamStatistics {
|
||
id BigInt @id @default(autoincrement()) @map("statistics_id")
|
||
userId BigInt @unique @map("user_id")
|
||
|
||
// 个人认种
|
||
selfPlantingCount Int @default(0) @map("self_planting_count") // 自己认种数量
|
||
selfPlantingAmount Decimal @default(0) @map("self_planting_amount") @db.Decimal(20, 8)
|
||
|
||
// 团队认种 (包含自己和所有下级)
|
||
teamPlantingCount Int @default(0) @map("team_planting_count") // 团队总认种数量
|
||
teamPlantingAmount Decimal @default(0) @map("team_planting_amount") @db.Decimal(20, 8)
|
||
|
||
// 直推团队认种 (按直推分组)
|
||
directTeamPlantingData Json @default("[]") @map("direct_team_planting_data")
|
||
// 格式: [{ userId: bigint, count: int, amount: decimal }, ...]
|
||
|
||
// 龙虎榜分值
|
||
// 计算公式: 团队总认种量 - 最大单个直推团队认种量
|
||
leaderboardScore Int @default(0) @map("leaderboard_score")
|
||
maxDirectTeamCount Int @default(0) @map("max_direct_team_count") // 最大直推团队认种量
|
||
|
||
// 省市分布 (用于计算省/市团队占比)
|
||
provinceCityDistribution Json @default("{}") @map("province_city_distribution")
|
||
// 格式: { "province_code": { "city_code": count, ... }, ... }
|
||
|
||
// 团队层级统计
|
||
teamMemberCount Int @default(0) @map("team_member_count") // 团队总人数
|
||
directMemberCount Int @default(0) @map("direct_member_count") // 直推人数
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
// 关联
|
||
referralRelationship ReferralRelationship @relation(fields: [userId], references: [userId])
|
||
|
||
@@map("team_statistics")
|
||
@@index([leaderboardScore(sort: Desc)])
|
||
@@index([teamPlantingCount(sort: Desc)])
|
||
@@index([selfPlantingCount])
|
||
}
|
||
|
||
// ============================================
|
||
// 直推列表表 (便于分页查询)
|
||
// ============================================
|
||
model DirectReferral {
|
||
id BigInt @id @default(autoincrement()) @map("direct_referral_id")
|
||
referrerId BigInt @map("referrer_id")
|
||
referralId BigInt @map("referral_id")
|
||
|
||
// 被推荐人信息快照 (冗余存储,避免跨服务查询)
|
||
referralNickname String? @map("referral_nickname") @db.VarChar(100)
|
||
referralAvatar String? @map("referral_avatar") @db.VarChar(255)
|
||
referralAccountNo String? @map("referral_account_no") @db.VarChar(20)
|
||
|
||
// 该直推的认种统计
|
||
plantingCount Int @default(0) @map("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])
|
||
@@map("direct_referrals")
|
||
@@index([referrerId])
|
||
@@index([referralId])
|
||
@@index([hasPlanted])
|
||
@@index([teamPlantingCount(sort: Desc)])
|
||
}
|
||
|
||
// ============================================
|
||
// 省市团队排名表 (用于省/市权益分配)
|
||
// ============================================
|
||
model ProvinceCityTeamRanking {
|
||
id BigInt @id @default(autoincrement()) @map("ranking_id")
|
||
provinceCode String @map("province_code") @db.VarChar(10)
|
||
cityCode String? @map("city_code") @db.VarChar(10) // null = 省级排名
|
||
|
||
userId BigInt @map("user_id")
|
||
|
||
// 该用户在此省/市的团队认种量
|
||
plantingCount Int @default(0) @map("planting_count")
|
||
|
||
// 占比 (百分比,如 25.50 表示 25.50%)
|
||
percentage Decimal @default(0) @map("percentage") @db.Decimal(10, 4)
|
||
|
||
// 排名
|
||
rank Int @default(0) @map("rank")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@unique([provinceCode, cityCode, userId])
|
||
@@map("province_city_team_rankings")
|
||
@@index([provinceCode, cityCode])
|
||
@@index([provinceCode, plantingCount(sort: Desc)])
|
||
@@index([userId])
|
||
}
|
||
|
||
// ============================================
|
||
// 推荐事件表 (行为表, 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")
|
||
version Int @default(1) @map("version")
|
||
|
||
@@map("referral_events")
|
||
@@index([aggregateType, aggregateId])
|
||
@@index([eventType])
|
||
@@index([userId])
|
||
@@index([occurredAt])
|
||
}
|
||
```
|
||
|
||
### 2.2 初始化数据库
|
||
|
||
```bash
|
||
npx prisma migrate dev --name init
|
||
npx prisma generate
|
||
```
|
||
|
||
---
|
||
|
||
## 第三阶段:领域层实现
|
||
|
||
### 3.1 值对象 (Value Objects)
|
||
|
||
#### 3.1.1 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);
|
||
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.2 referral-chain.vo.ts
|
||
```typescript
|
||
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);
|
||
}
|
||
|
||
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.3 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 = Math.max(0, ...directTeamCounts);
|
||
const score = totalTeamCount - maxDirectTeamCount;
|
||
|
||
return new LeaderboardScore(
|
||
totalTeamCount,
|
||
maxDirectTeamCount,
|
||
Math.max(0, score), // 分值不能为负
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 当团队认种发生变化时重新计算
|
||
*/
|
||
recalculate(
|
||
newTotalTeamCount: number,
|
||
newDirectTeamCounts: number[],
|
||
): LeaderboardScore {
|
||
return LeaderboardScore.calculate(newTotalTeamCount, newDirectTeamCounts);
|
||
}
|
||
|
||
/**
|
||
* 比较排名
|
||
*/
|
||
compareTo(other: LeaderboardScore): number {
|
||
return other.score - this.score; // 降序
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.4 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>>): ProvinceCityDistribution {
|
||
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;
|
||
}
|
||
|
||
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.2 聚合根 (Aggregates)
|
||
|
||
#### 3.2.1 referral-relationship.aggregate.ts
|
||
|
||
```typescript
|
||
import { ReferralCode } from '../value-objects/referral-code.vo';
|
||
import { ReferralChain } from '../value-objects/referral-chain.vo';
|
||
|
||
export class ReferralRelationship {
|
||
private _id: bigint | null;
|
||
private readonly _userId: bigint;
|
||
private readonly _myReferralCode: ReferralCode;
|
||
private readonly _usedReferralCode: ReferralCode | null;
|
||
private readonly _referrerId: bigint | null;
|
||
private readonly _referralChain: ReferralChain;
|
||
private _directReferralCount: number;
|
||
private _activeDirectCount: number;
|
||
private readonly _createdAt: Date;
|
||
|
||
private _domainEvents: any[] = [];
|
||
|
||
private constructor(
|
||
userId: bigint,
|
||
myReferralCode: ReferralCode,
|
||
usedReferralCode: ReferralCode | null,
|
||
referrerId: bigint | null,
|
||
referralChain: ReferralChain,
|
||
) {
|
||
this._userId = userId;
|
||
this._myReferralCode = myReferralCode;
|
||
this._usedReferralCode = usedReferralCode;
|
||
this._referrerId = referrerId;
|
||
this._referralChain = referralChain;
|
||
this._directReferralCount = 0;
|
||
this._activeDirectCount = 0;
|
||
this._createdAt = new Date();
|
||
}
|
||
|
||
// Getters
|
||
get id(): bigint | null { return this._id; }
|
||
get userId(): bigint { return this._userId; }
|
||
get myReferralCode(): ReferralCode { return this._myReferralCode; }
|
||
get usedReferralCode(): ReferralCode | null { return this._usedReferralCode; }
|
||
get referrerId(): bigint | null { return this._referrerId; }
|
||
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 domainEvents(): any[] { return this._domainEvents; }
|
||
|
||
/**
|
||
* 工厂方法:创建推荐关系
|
||
* @param userId 用户ID
|
||
* @param referrer 推荐人 (可选)
|
||
* @param usedReferralCode 使用的推荐码 (可选)
|
||
*/
|
||
static create(
|
||
userId: bigint,
|
||
referrer: ReferralRelationship | null,
|
||
usedReferralCode: string | null,
|
||
): ReferralRelationship {
|
||
const myCode = ReferralCode.generate(userId);
|
||
const usedCode = usedReferralCode ? ReferralCode.create(usedReferralCode) : null;
|
||
|
||
// 构建推荐链
|
||
const referralChain = ReferralChain.create(
|
||
referrer?.userId ?? null,
|
||
referrer?.referralChain.toArray() ?? [],
|
||
);
|
||
|
||
const relationship = new ReferralRelationship(
|
||
userId,
|
||
myCode,
|
||
usedCode,
|
||
referrer?.userId ?? null,
|
||
referralChain,
|
||
);
|
||
|
||
// 发布领域事件
|
||
relationship._domainEvents.push({
|
||
type: 'ReferralRelationshipCreated',
|
||
data: {
|
||
userId: userId.toString(),
|
||
referrerId: referrer?.userId?.toString() ?? null,
|
||
myReferralCode: myCode.value,
|
||
usedReferralCode: usedCode?.value ?? null,
|
||
depth: referralChain.depth,
|
||
},
|
||
});
|
||
|
||
return relationship;
|
||
}
|
||
|
||
/**
|
||
* 增加直推人数
|
||
*/
|
||
incrementDirectReferralCount(): void {
|
||
this._directReferralCount++;
|
||
}
|
||
|
||
/**
|
||
* 直推用户认种后,更新活跃直推数
|
||
*/
|
||
markDirectAsActive(directUserId: bigint): void {
|
||
this._activeDirectCount++;
|
||
}
|
||
|
||
/**
|
||
* 获取所有上级用户ID (用于更新团队统计)
|
||
*/
|
||
getAllAncestorIds(): bigint[] {
|
||
return this._referralChain.getAllAncestors();
|
||
}
|
||
|
||
clearDomainEvents(): void {
|
||
this._domainEvents = [];
|
||
}
|
||
|
||
// 从数据库重建
|
||
static reconstitute(data: any): ReferralRelationship {
|
||
const relationship = new ReferralRelationship(
|
||
data.userId,
|
||
ReferralCode.create(data.myReferralCode),
|
||
data.usedReferralCode ? ReferralCode.create(data.usedReferralCode) : null,
|
||
data.referrerId,
|
||
ReferralChain.fromArray(data.referralChain || []),
|
||
);
|
||
relationship._id = data.id;
|
||
relationship._directReferralCount = data.directReferralCount ?? 0;
|
||
relationship._activeDirectCount = data.activeDirectCount ?? 0;
|
||
return relationship;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 team-statistics.aggregate.ts
|
||
|
||
```typescript
|
||
import { LeaderboardScore } from '../value-objects/leaderboard-score.vo';
|
||
import { ProvinceCityDistribution } from '../value-objects/province-city-distribution.vo';
|
||
|
||
interface DirectTeamData {
|
||
userId: bigint;
|
||
count: number;
|
||
amount: number;
|
||
}
|
||
|
||
export class TeamStatistics {
|
||
private _id: bigint | null;
|
||
private readonly _userId: bigint;
|
||
|
||
// 个人认种
|
||
private _selfPlantingCount: number;
|
||
private _selfPlantingAmount: number;
|
||
|
||
// 团队认种
|
||
private _teamPlantingCount: number;
|
||
private _teamPlantingAmount: number;
|
||
|
||
// 直推团队数据
|
||
private _directTeamPlantingData: DirectTeamData[];
|
||
|
||
// 龙虎榜
|
||
private _leaderboardScore: LeaderboardScore;
|
||
|
||
// 省市分布
|
||
private _provinceCityDistribution: ProvinceCityDistribution;
|
||
|
||
// 团队人数
|
||
private _teamMemberCount: number;
|
||
private _directMemberCount: number;
|
||
|
||
private _domainEvents: any[] = [];
|
||
|
||
private constructor(userId: bigint) {
|
||
this._userId = userId;
|
||
this._selfPlantingCount = 0;
|
||
this._selfPlantingAmount = 0;
|
||
this._teamPlantingCount = 0;
|
||
this._teamPlantingAmount = 0;
|
||
this._directTeamPlantingData = [];
|
||
this._leaderboardScore = LeaderboardScore.calculate(0, []);
|
||
this._provinceCityDistribution = ProvinceCityDistribution.empty();
|
||
this._teamMemberCount = 0;
|
||
this._directMemberCount = 0;
|
||
}
|
||
|
||
// Getters
|
||
get id(): bigint | null { return this._id; }
|
||
get userId(): bigint { return this._userId; }
|
||
get selfPlantingCount(): number { return this._selfPlantingCount; }
|
||
get selfPlantingAmount(): number { return this._selfPlantingAmount; }
|
||
get teamPlantingCount(): number { return this._teamPlantingCount; }
|
||
get teamPlantingAmount(): number { return this._teamPlantingAmount; }
|
||
get directTeamPlantingData(): ReadonlyArray<DirectTeamData> { return this._directTeamPlantingData; }
|
||
get leaderboardScore(): LeaderboardScore { return this._leaderboardScore; }
|
||
get provinceCityDistribution(): ProvinceCityDistribution { return this._provinceCityDistribution; }
|
||
get teamMemberCount(): number { return this._teamMemberCount; }
|
||
get directMemberCount(): number { return this._directMemberCount; }
|
||
get domainEvents(): any[] { return this._domainEvents; }
|
||
|
||
/**
|
||
* 工厂方法:创建团队统计
|
||
*/
|
||
static create(userId: bigint): TeamStatistics {
|
||
return new TeamStatistics(userId);
|
||
}
|
||
|
||
/**
|
||
* 记录自己的认种
|
||
*/
|
||
addSelfPlanting(
|
||
treeCount: number,
|
||
amount: number,
|
||
provinceCode: string,
|
||
cityCode: string,
|
||
): void {
|
||
this._selfPlantingCount += treeCount;
|
||
this._selfPlantingAmount += amount;
|
||
|
||
// 自己的认种也计入团队
|
||
this._teamPlantingCount += treeCount;
|
||
this._teamPlantingAmount += amount;
|
||
|
||
// 更新省市分布
|
||
this._provinceCityDistribution = this._provinceCityDistribution.add(
|
||
provinceCode,
|
||
cityCode,
|
||
treeCount,
|
||
);
|
||
|
||
// 重新计算龙虎榜分值
|
||
this._recalculateLeaderboardScore();
|
||
|
||
this._domainEvents.push({
|
||
type: 'TeamStatisticsUpdated',
|
||
data: {
|
||
userId: this._userId.toString(),
|
||
selfPlantingCount: this._selfPlantingCount,
|
||
teamPlantingCount: this._teamPlantingCount,
|
||
leaderboardScore: this._leaderboardScore.score,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 下级用户认种,更新团队统计
|
||
* @param directUserId 直推用户ID (第一层下级)
|
||
* @param treeCount 认种数量
|
||
* @param amount 认种金额
|
||
* @param provinceCode 省份代码
|
||
* @param cityCode 城市代码
|
||
*/
|
||
addTeamPlanting(
|
||
directUserId: bigint,
|
||
treeCount: number,
|
||
amount: number,
|
||
provinceCode: string,
|
||
cityCode: string,
|
||
): void {
|
||
// 更新团队总量
|
||
this._teamPlantingCount += treeCount;
|
||
this._teamPlantingAmount += amount;
|
||
|
||
// 更新直推团队数据
|
||
const directTeamIndex = this._directTeamPlantingData.findIndex(
|
||
d => d.userId === directUserId,
|
||
);
|
||
|
||
if (directTeamIndex >= 0) {
|
||
this._directTeamPlantingData[directTeamIndex].count += treeCount;
|
||
this._directTeamPlantingData[directTeamIndex].amount += amount;
|
||
} else {
|
||
this._directTeamPlantingData.push({
|
||
userId: directUserId,
|
||
count: treeCount,
|
||
amount,
|
||
});
|
||
}
|
||
|
||
// 更新省市分布
|
||
this._provinceCityDistribution = this._provinceCityDistribution.add(
|
||
provinceCode,
|
||
cityCode,
|
||
treeCount,
|
||
);
|
||
|
||
// 重新计算龙虎榜分值
|
||
this._recalculateLeaderboardScore();
|
||
|
||
this._domainEvents.push({
|
||
type: 'TeamStatisticsUpdated',
|
||
data: {
|
||
userId: this._userId.toString(),
|
||
teamPlantingCount: this._teamPlantingCount,
|
||
leaderboardScore: this._leaderboardScore.score,
|
||
updatedByDirectUserId: directUserId.toString(),
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 添加团队成员
|
||
* @param isDirect 是否是直推
|
||
*/
|
||
addTeamMember(isDirect: boolean): void {
|
||
this._teamMemberCount++;
|
||
if (isDirect) {
|
||
this._directMemberCount++;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重新计算龙虎榜分值
|
||
*/
|
||
private _recalculateLeaderboardScore(): void {
|
||
const directCounts = this._directTeamPlantingData.map(d => d.count);
|
||
this._leaderboardScore = LeaderboardScore.calculate(
|
||
this._teamPlantingCount,
|
||
directCounts,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 获取最大直推团队认种量
|
||
*/
|
||
get maxDirectTeamCount(): number {
|
||
return this._leaderboardScore.maxDirectTeamCount;
|
||
}
|
||
|
||
clearDomainEvents(): void {
|
||
this._domainEvents = [];
|
||
}
|
||
|
||
// 从数据库重建
|
||
static reconstitute(data: any): TeamStatistics {
|
||
const stats = new TeamStatistics(data.userId);
|
||
stats._id = data.id;
|
||
stats._selfPlantingCount = data.selfPlantingCount ?? 0;
|
||
stats._selfPlantingAmount = Number(data.selfPlantingAmount) ?? 0;
|
||
stats._teamPlantingCount = data.teamPlantingCount ?? 0;
|
||
stats._teamPlantingAmount = Number(data.teamPlantingAmount) ?? 0;
|
||
stats._directTeamPlantingData = data.directTeamPlantingData ?? [];
|
||
stats._teamMemberCount = data.teamMemberCount ?? 0;
|
||
stats._directMemberCount = data.directMemberCount ?? 0;
|
||
|
||
// 重建省市分布
|
||
stats._provinceCityDistribution = ProvinceCityDistribution.fromJson(
|
||
data.provinceCityDistribution ?? {},
|
||
);
|
||
|
||
// 重新计算龙虎榜分值
|
||
stats._recalculateLeaderboardScore();
|
||
|
||
return stats;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 领域服务
|
||
|
||
#### 3.3.1 referral-chain.service.ts
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { ReferralRelationship } from '../aggregates/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, ...relationship1.getAllAncestorIds()];
|
||
const chain2 = [relationship2.userId, ...relationship2.getAllAncestorIds()];
|
||
|
||
// 检查 user2 是否在 user1 的链上
|
||
const idx1 = chain1.findIndex(id => id === relationship2.userId);
|
||
if (idx1 >= 0) return idx1;
|
||
|
||
// 检查 user1 是否在 user2 的链上
|
||
const idx2 = chain2.findIndex(id => id === relationship1.userId);
|
||
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.3.2 leaderboard-calculator.service.ts
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { TeamStatistics } from '../aggregates/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.leaderboardScore.score > 0)
|
||
.sort((a, b) => b.leaderboardScore.score - a.leaderboardScore.score);
|
||
|
||
// 取前 limit 名并添加排名
|
||
return sorted.slice(0, limit).map((stats, index) => ({
|
||
userId: stats.userId,
|
||
score: stats.leaderboardScore.score,
|
||
teamPlantingCount: stats.teamPlantingCount,
|
||
maxDirectTeamCount: stats.maxDirectTeamCount,
|
||
rank: index + 1,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 获取用户在龙虎榜中的排名
|
||
*/
|
||
getUserRank(
|
||
allStats: TeamStatistics[],
|
||
userId: bigint,
|
||
): number | null {
|
||
const sorted = allStats
|
||
.filter(s => s.leaderboardScore.score > 0)
|
||
.sort((a, b) => b.leaderboardScore.score - a.leaderboardScore.score);
|
||
|
||
const index = sorted.findIndex(s => s.userId === userId);
|
||
return index >= 0 ? index + 1 : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.3 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.aggregate';
|
||
|
||
@Injectable()
|
||
export class TeamAggregationService {
|
||
constructor(
|
||
@Inject(TEAM_STATISTICS_REPOSITORY)
|
||
private readonly statsRepository: ITeamStatisticsRepository,
|
||
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
|
||
private readonly relationshipRepository: IReferralRelationshipRepository,
|
||
) {}
|
||
|
||
/**
|
||
* 当用户认种时,更新所有上级的团队统计
|
||
*
|
||
* @param userId 认种的用户ID
|
||
* @param treeCount 认种数量
|
||
* @param amount 认种金额
|
||
* @param provinceCode 省份代码
|
||
* @param cityCode 城市代码
|
||
*/
|
||
async updateAncestorTeamStats(
|
||
userId: bigint,
|
||
treeCount: number,
|
||
amount: number,
|
||
provinceCode: string,
|
||
cityCode: string,
|
||
): Promise<void> {
|
||
// 1. 获取用户的推荐关系
|
||
const relationship = await this.relationshipRepository.findByUserId(userId);
|
||
if (!relationship) {
|
||
throw new Error(`用户 ${userId} 的推荐关系不存在`);
|
||
}
|
||
|
||
// 2. 更新自己的统计
|
||
let selfStats = await this.statsRepository.findByUserId(userId);
|
||
if (!selfStats) {
|
||
selfStats = TeamStatistics.create(userId);
|
||
}
|
||
selfStats.addSelfPlanting(treeCount, amount, provinceCode, cityCode);
|
||
await this.statsRepository.save(selfStats);
|
||
|
||
// 3. 获取推荐链 (所有上级)
|
||
const ancestors = relationship.getAllAncestorIds();
|
||
if (ancestors.length === 0) return;
|
||
|
||
// 4. 找到直接推荐人 (第一层上级)
|
||
const directReferrerId = ancestors[0];
|
||
|
||
// 5. 更新所有上级的团队统计
|
||
for (const ancestorId of ancestors) {
|
||
let ancestorStats = await this.statsRepository.findByUserId(ancestorId);
|
||
if (!ancestorStats) {
|
||
ancestorStats = TeamStatistics.create(ancestorId);
|
||
}
|
||
|
||
// 对于所有上级,都传入"通过哪个直推"来的数据
|
||
// 需要追溯:这个认种是哪个直推带来的
|
||
const directUserIdForAncestor = this.findDirectReferralForAncestor(
|
||
userId,
|
||
ancestorId,
|
||
ancestors,
|
||
);
|
||
|
||
ancestorStats.addTeamPlanting(
|
||
directUserIdForAncestor,
|
||
treeCount,
|
||
amount,
|
||
provinceCode,
|
||
cityCode,
|
||
);
|
||
await this.statsRepository.save(ancestorStats);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 找出对于某个祖先来说,这个认种是通过哪个直推传递上来的
|
||
*/
|
||
private findDirectReferralForAncestor(
|
||
plantingUserId: bigint,
|
||
ancestorId: bigint,
|
||
fullChain: bigint[],
|
||
): bigint {
|
||
// fullChain: [直接推荐人, 上上级, 上上上级, ...]
|
||
// 如果 ancestorId 是直接推荐人,那么直推就是 plantingUserId
|
||
// 如果 ancestorId 是上上级,那么直推就是直接推荐人 (fullChain[0])
|
||
// 以此类推
|
||
|
||
const ancestorIndex = fullChain.findIndex(id => id === ancestorId);
|
||
if (ancestorIndex === 0) {
|
||
// ancestorId 是直接推荐人,所以直推就是认种的用户本身
|
||
return plantingUserId;
|
||
} else if (ancestorIndex > 0) {
|
||
// ancestorId 在链的更高位置,直推是它的下一级
|
||
return fullChain[ancestorIndex - 1];
|
||
}
|
||
|
||
// 不应该到这里
|
||
return plantingUserId;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 仓储接口
|
||
|
||
#### 3.4.1 referral-relationship.repository.interface.ts
|
||
|
||
```typescript
|
||
import { ReferralRelationship } from '../aggregates/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[]>;
|
||
}
|
||
|
||
export const REFERRAL_RELATIONSHIP_REPOSITORY = Symbol('IReferralRelationshipRepository');
|
||
```
|
||
|
||
#### 3.4.2 team-statistics.repository.interface.ts
|
||
|
||
```typescript
|
||
import { TeamStatistics } from '../aggregates/team-statistics.aggregate';
|
||
|
||
export interface ITeamStatisticsRepository {
|
||
save(stats: TeamStatistics): Promise<void>;
|
||
findByUserId(userId: bigint): Promise<TeamStatistics | null>;
|
||
findTopByLeaderboardScore(limit: number): Promise<TeamStatistics[]>;
|
||
findByProvinceCityRanking(
|
||
provinceCode: string,
|
||
cityCode: string | null,
|
||
limit: number,
|
||
): Promise<TeamStatistics[]>;
|
||
getOrCreate(userId: bigint): Promise<TeamStatistics>;
|
||
}
|
||
|
||
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository');
|
||
```
|
||
|
||
---
|
||
|
||
## 第四阶段:应用层实现
|
||
|
||
### 4.1 应用服务
|
||
|
||
```typescript
|
||
// application/services/referral-application.service.ts
|
||
|
||
import { Injectable, Inject } from '@nestjs/common';
|
||
import { ReferralRelationship } from '../../domain/aggregates/referral-relationship.aggregate';
|
||
import { TeamStatistics } from '../../domain/aggregates/team-statistics.aggregate';
|
||
import {
|
||
IReferralRelationshipRepository,
|
||
REFERRAL_RELATIONSHIP_REPOSITORY
|
||
} from '../../domain/repositories/referral-relationship.repository.interface';
|
||
import {
|
||
ITeamStatisticsRepository,
|
||
TEAM_STATISTICS_REPOSITORY
|
||
} from '../../domain/repositories/team-statistics.repository.interface';
|
||
import { ReferralChainService } from '../../domain/services/referral-chain.service';
|
||
import { LeaderboardCalculatorService } from '../../domain/services/leaderboard-calculator.service';
|
||
|
||
@Injectable()
|
||
export class ReferralApplicationService {
|
||
constructor(
|
||
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
|
||
private readonly relationshipRepository: IReferralRelationshipRepository,
|
||
@Inject(TEAM_STATISTICS_REPOSITORY)
|
||
private readonly statsRepository: ITeamStatisticsRepository,
|
||
private readonly referralChainService: ReferralChainService,
|
||
private readonly leaderboardService: LeaderboardCalculatorService,
|
||
) {}
|
||
|
||
/**
|
||
* 创建推荐关系 (用户注册时调用)
|
||
*/
|
||
async createRelationship(
|
||
userId: bigint,
|
||
referralCode: string | null,
|
||
) {
|
||
// 检查是否已存在
|
||
const existing = await this.relationshipRepository.findByUserId(userId);
|
||
if (existing) {
|
||
throw new Error('用户推荐关系已存在');
|
||
}
|
||
|
||
// 查找推荐人
|
||
let referrer: ReferralRelationship | null = null;
|
||
if (referralCode) {
|
||
referrer = await this.relationshipRepository.findByReferralCode(referralCode);
|
||
if (!referrer) {
|
||
throw new Error('无效的推荐码');
|
||
}
|
||
}
|
||
|
||
// 创建推荐关系
|
||
const relationship = ReferralRelationship.create(userId, referrer, referralCode);
|
||
await this.relationshipRepository.save(relationship);
|
||
|
||
// 更新推荐人的直推计数
|
||
if (referrer) {
|
||
referrer.incrementDirectReferralCount();
|
||
await this.relationshipRepository.save(referrer);
|
||
|
||
// 更新推荐人的团队人数
|
||
let referrerStats = await this.statsRepository.findByUserId(referrer.userId);
|
||
if (!referrerStats) {
|
||
referrerStats = TeamStatistics.create(referrer.userId);
|
||
}
|
||
referrerStats.addTeamMember(true);
|
||
await this.statsRepository.save(referrerStats);
|
||
|
||
// 更新所有上级的团队人数
|
||
for (const ancestorId of referrer.getAllAncestorIds()) {
|
||
let ancestorStats = await this.statsRepository.findByUserId(ancestorId);
|
||
if (!ancestorStats) {
|
||
ancestorStats = TeamStatistics.create(ancestorId);
|
||
}
|
||
ancestorStats.addTeamMember(false);
|
||
await this.statsRepository.save(ancestorStats);
|
||
}
|
||
}
|
||
|
||
// 创建用户自己的团队统计
|
||
const userStats = TeamStatistics.create(userId);
|
||
await this.statsRepository.save(userStats);
|
||
|
||
return {
|
||
myReferralCode: relationship.myReferralCode.value,
|
||
referrerId: referrer?.userId.toString() ?? null,
|
||
depth: relationship.depth,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取我的推荐信息 (分享页)
|
||
*/
|
||
async getMyReferralInfo(userId: bigint) {
|
||
const relationship = await this.relationshipRepository.findByUserId(userId);
|
||
if (!relationship) {
|
||
throw new Error('用户推荐关系不存在');
|
||
}
|
||
|
||
const stats = await this.statsRepository.findByUserId(userId);
|
||
|
||
return {
|
||
myReferralCode: relationship.myReferralCode.value,
|
||
directReferralCount: relationship.directReferralCount,
|
||
activeDirectCount: relationship.activeDirectCount,
|
||
teamMemberCount: stats?.teamMemberCount ?? 0,
|
||
teamPlantingCount: stats?.teamPlantingCount ?? 0,
|
||
leaderboardScore: stats?.leaderboardScore.score ?? 0,
|
||
leaderboardRank: await this.getUserLeaderboardRank(userId),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取直推列表
|
||
*/
|
||
async getDirectReferrals(userId: bigint, page = 1, pageSize = 20) {
|
||
const directReferrals = await this.relationshipRepository.findDirectReferrals(
|
||
userId,
|
||
page,
|
||
pageSize,
|
||
);
|
||
|
||
const result = [];
|
||
for (const referral of directReferrals) {
|
||
const stats = await this.statsRepository.findByUserId(referral.userId);
|
||
result.push({
|
||
userId: referral.userId.toString(),
|
||
referralCode: referral.myReferralCode.value,
|
||
plantingCount: stats?.selfPlantingCount ?? 0,
|
||
teamPlantingCount: stats?.teamPlantingCount ?? 0,
|
||
hasPlanted: (stats?.selfPlantingCount ?? 0) > 0,
|
||
});
|
||
}
|
||
|
||
const total = await this.relationshipRepository.countDirectReferrals(userId);
|
||
|
||
return {
|
||
list: result,
|
||
total,
|
||
page,
|
||
pageSize,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取推荐链 (用于分享权益分配)
|
||
*/
|
||
async getReferralChain(userId: bigint): Promise<string[]> {
|
||
const relationship = await this.relationshipRepository.findByUserId(userId);
|
||
if (!relationship) {
|
||
return [];
|
||
}
|
||
return relationship.getAllAncestorIds().map(id => id.toString());
|
||
}
|
||
|
||
/**
|
||
* 获取团队统计
|
||
*/
|
||
async getTeamStatistics(userId: bigint) {
|
||
const stats = await this.statsRepository.findByUserId(userId);
|
||
if (!stats) {
|
||
return {
|
||
selfPlantingCount: 0,
|
||
teamPlantingCount: 0,
|
||
teamMemberCount: 0,
|
||
directMemberCount: 0,
|
||
leaderboardScore: 0,
|
||
provinceCityDistribution: {},
|
||
};
|
||
}
|
||
|
||
return {
|
||
selfPlantingCount: stats.selfPlantingCount,
|
||
teamPlantingCount: stats.teamPlantingCount,
|
||
teamMemberCount: stats.teamMemberCount,
|
||
directMemberCount: stats.directMemberCount,
|
||
leaderboardScore: stats.leaderboardScore.score,
|
||
provinceCityDistribution: stats.provinceCityDistribution.toJson(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取龙虎榜
|
||
*/
|
||
async getLeaderboard(limit = 100) {
|
||
const allStats = await this.statsRepository.findTopByLeaderboardScore(limit);
|
||
return this.leaderboardService.calculateLeaderboard(allStats, limit);
|
||
}
|
||
|
||
/**
|
||
* 获取用户龙虎榜排名
|
||
*/
|
||
private async getUserLeaderboardRank(userId: bigint): Promise<number | null> {
|
||
const allStats = await this.statsRepository.findTopByLeaderboardScore(1000);
|
||
return this.leaderboardService.getUserRank(allStats, userId);
|
||
}
|
||
|
||
/**
|
||
* 获取省/市团队排名
|
||
*/
|
||
async getProvinceCityTeamRanking(
|
||
provinceCode: string,
|
||
cityCode: string | null,
|
||
limit = 50,
|
||
) {
|
||
const stats = await this.statsRepository.findByProvinceCityRanking(
|
||
provinceCode,
|
||
cityCode,
|
||
limit,
|
||
);
|
||
|
||
// 计算总量
|
||
let totalPlanting = 0;
|
||
for (const s of stats) {
|
||
totalPlanting += cityCode
|
||
? s.provinceCityDistribution.getCityTotal(provinceCode, cityCode)
|
||
: s.provinceCityDistribution.getProvinceTotal(provinceCode);
|
||
}
|
||
|
||
// 计算每个用户的占比
|
||
return stats.map((s, index) => {
|
||
const userPlanting = cityCode
|
||
? s.provinceCityDistribution.getCityTotal(provinceCode, cityCode)
|
||
: s.provinceCityDistribution.getProvinceTotal(provinceCode);
|
||
|
||
return {
|
||
userId: s.userId.toString(),
|
||
plantingCount: userPlanting,
|
||
percentage: totalPlanting > 0
|
||
? ((userPlanting / totalPlanting) * 100).toFixed(2)
|
||
: '0.00',
|
||
rank: index + 1,
|
||
};
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 第五阶段:API层实现
|
||
|
||
### 5.1 DTO 定义
|
||
|
||
```typescript
|
||
// api/dto/referral-info.dto.ts
|
||
export class ReferralInfoDto {
|
||
myReferralCode: string;
|
||
directReferralCount: number;
|
||
activeDirectCount: number;
|
||
teamMemberCount: number;
|
||
teamPlantingCount: number;
|
||
leaderboardScore: number;
|
||
leaderboardRank: number | null;
|
||
}
|
||
|
||
// api/dto/direct-referral.dto.ts
|
||
export class DirectReferralDto {
|
||
userId: string;
|
||
referralCode: string;
|
||
plantingCount: number;
|
||
teamPlantingCount: number;
|
||
hasPlanted: boolean;
|
||
}
|
||
|
||
// api/dto/team-statistics.dto.ts
|
||
export class TeamStatisticsDto {
|
||
selfPlantingCount: number;
|
||
teamPlantingCount: number;
|
||
teamMemberCount: number;
|
||
directMemberCount: number;
|
||
leaderboardScore: number;
|
||
provinceCityDistribution: Record<string, Record<string, number>>;
|
||
}
|
||
|
||
// api/dto/leaderboard.dto.ts
|
||
export class LeaderboardEntryDto {
|
||
userId: string;
|
||
score: number;
|
||
teamPlantingCount: number;
|
||
maxDirectTeamCount: number;
|
||
rank: number;
|
||
}
|
||
```
|
||
|
||
### 5.2 控制器
|
||
|
||
```typescript
|
||
// api/controllers/referral.controller.ts
|
||
|
||
import { Controller, Get, Post, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||
import { ReferralApplicationService } from '../../application/services/referral-application.service';
|
||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||
|
||
@Controller('referrals')
|
||
@UseGuards(JwtAuthGuard)
|
||
export class ReferralController {
|
||
constructor(private readonly referralService: ReferralApplicationService) {}
|
||
|
||
/**
|
||
* 获取我的推荐信息 (分享页)
|
||
*/
|
||
@Get('me')
|
||
async getMyReferralInfo(@Req() req: any) {
|
||
const userId = BigInt(req.user.id);
|
||
return this.referralService.getMyReferralInfo(userId);
|
||
}
|
||
|
||
/**
|
||
* 获取直推列表
|
||
*/
|
||
@Get('direct')
|
||
async getDirectReferrals(
|
||
@Req() req: any,
|
||
@Query('page') page = 1,
|
||
@Query('pageSize') pageSize = 20,
|
||
) {
|
||
const userId = BigInt(req.user.id);
|
||
return this.referralService.getDirectReferrals(userId, page, pageSize);
|
||
}
|
||
|
||
/**
|
||
* 获取推荐链 (内部API,用于资金分配)
|
||
*/
|
||
@Get('chain')
|
||
async getReferralChain(@Req() req: any) {
|
||
const userId = BigInt(req.user.id);
|
||
return this.referralService.getReferralChain(userId);
|
||
}
|
||
}
|
||
|
||
// api/controllers/team.controller.ts
|
||
|
||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||
import { ReferralApplicationService } from '../../application/services/referral-application.service';
|
||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||
|
||
@Controller('team')
|
||
@UseGuards(JwtAuthGuard)
|
||
export class TeamController {
|
||
constructor(private readonly referralService: ReferralApplicationService) {}
|
||
|
||
/**
|
||
* 获取我的团队统计
|
||
*/
|
||
@Get('statistics')
|
||
async getTeamStatistics(@Req() req: any) {
|
||
const userId = BigInt(req.user.id);
|
||
return this.referralService.getTeamStatistics(userId);
|
||
}
|
||
|
||
/**
|
||
* 获取省/市团队排名
|
||
*/
|
||
@Get('province-city-ranking')
|
||
async getProvinceCityRanking(
|
||
@Query('provinceCode') provinceCode: string,
|
||
@Query('cityCode') cityCode: string | null,
|
||
@Query('limit') limit = 50,
|
||
) {
|
||
return this.referralService.getProvinceCityTeamRanking(provinceCode, cityCode, limit);
|
||
}
|
||
}
|
||
|
||
// api/controllers/leaderboard.controller.ts
|
||
|
||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||
import { ReferralApplicationService } from '../../application/services/referral-application.service';
|
||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||
|
||
@Controller('leaderboard')
|
||
@UseGuards(JwtAuthGuard)
|
||
export class LeaderboardController {
|
||
constructor(private readonly referralService: ReferralApplicationService) {}
|
||
|
||
/**
|
||
* 获取龙虎榜
|
||
*/
|
||
@Get()
|
||
async getLeaderboard(@Query('limit') limit = 100) {
|
||
return this.referralService.getLeaderboard(limit);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 事件处理 (Kafka)
|
||
|
||
### 当用户认种时更新团队统计
|
||
|
||
```typescript
|
||
// infrastructure/kafka/event-consumer.controller.ts
|
||
|
||
import { Controller } from '@nestjs/common';
|
||
import { MessagePattern, Payload } from '@nestjs/microservices';
|
||
import { TeamAggregationService } from '../../domain/services/team-aggregation.service';
|
||
|
||
interface PlantingOrderPaidEvent {
|
||
userId: string;
|
||
orderNo: string;
|
||
treeCount: number;
|
||
totalAmount: number;
|
||
provinceCode: string;
|
||
cityCode: string;
|
||
}
|
||
|
||
@Controller()
|
||
export class EventConsumerController {
|
||
constructor(
|
||
private readonly teamAggregationService: TeamAggregationService,
|
||
) {}
|
||
|
||
/**
|
||
* 监听认种订单支付事件
|
||
* 更新认种用户及其所有上级的团队统计
|
||
*/
|
||
@MessagePattern('planting.order.paid')
|
||
async handlePlantingOrderPaid(@Payload() event: PlantingOrderPaidEvent) {
|
||
console.log('收到认种订单支付事件:', event);
|
||
|
||
await this.teamAggregationService.updateAncestorTeamStats(
|
||
BigInt(event.userId),
|
||
event.treeCount,
|
||
event.totalAmount,
|
||
event.provinceCode,
|
||
event.cityCode,
|
||
);
|
||
|
||
console.log('团队统计更新完成');
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 关键业务规则 (不变式)
|
||
|
||
1. **推荐关系不可修改**: 一旦建立推荐关系,终生不可修改
|
||
2. **推荐链最多10层**: 推荐链深度限制为10层,超过的不计入
|
||
3. **龙虎榜分值计算**: `团队总认种量 - 最大单个直推团队认种量`
|
||
4. **省市权益分配**: 根据用户团队在该省/市的认种占比分配
|
||
5. **团队统计实时更新**: 任何下级认种都会实时更新所有上级的统计
|
||
|
||
---
|
||
|
||
## API 端点汇总
|
||
|
||
| 方法 | 路径 | 描述 | 认证 |
|
||
|------|------|------|------|
|
||
| GET | /referrals/me | 获取我的推荐信息 (分享页) | 需要 |
|
||
| GET | /referrals/direct | 获取直推列表 | 需要 |
|
||
| GET | /referrals/chain | 获取推荐链 (内部API) | 需要 |
|
||
| GET | /team/statistics | 获取我的团队统计 | 需要 |
|
||
| GET | /team/province-city-ranking | 获取省/市团队排名 | 需要 |
|
||
| GET | /leaderboard | 获取龙虎榜 | 需要 |
|
||
|
||
---
|
||
|
||
## 开发顺序建议
|
||
|
||
1. 项目初始化和 Prisma Schema
|
||
2. 值对象和枚举实现
|
||
3. 聚合根实现 (ReferralRelationship, TeamStatistics)
|
||
4. 领域服务实现 (ReferralChainService, LeaderboardCalculatorService, TeamAggregationService)
|
||
5. 仓储接口和实现
|
||
6. 应用服务实现
|
||
7. Kafka 事件消费者
|
||
8. DTO 和控制器实现
|
||
9. 模块配置和测试
|
||
|
||
---
|
||
|
||
## 与前端页面对应关系
|
||
|
||
### 向导页5 (分享引导页)
|
||
- 显示用户的推荐码
|
||
- 显示分享链接/二维码
|
||
- 调用: `GET /referrals/me`
|
||
|
||
### 分享页
|
||
- 显示我的推荐码和分享链接
|
||
- 显示直推人数、团队人数
|
||
- 显示团队认种统计
|
||
- 调用: `GET /referrals/me`
|
||
- 调用: `GET /team/statistics`
|
||
|
||
### 直推列表
|
||
- 显示我的直推用户列表
|
||
- 显示每个直推的认种情况
|
||
- 调用: `GET /referrals/direct?page=1&pageSize=20`
|
||
|
||
### 龙虎榜
|
||
- 显示全平台龙虎榜排名
|
||
- 调用: `GET /leaderboard?limit=100`
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
1. 推荐关系在用户注册时由 identity-service 触发创建
|
||
2. 团队统计在认种支付完成时由 planting-service 发布事件触发更新
|
||
3. 龙虎榜分值设计目的是鼓励均衡发展团队
|
||
4. 省市权益分配需要根据省市团队占比计算
|
||
5. 使用 PostgreSQL 数组类型存储推荐链,方便查询
|
||
6. 直推团队数据使用 JSON 类型存储,便于灵活扩展
|