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