feat(referral-service): Implement complete referral service with DDD architecture
Implement the referral service microservice with comprehensive features: ## Domain Layer - ReferralRelationship aggregate: manages user referral relationships - TeamStatistics aggregate: tracks team statistics and leaderboard scores - Value Objects: UserId, ReferralCode, ReferralChain, LeaderboardScore, ProvinceCityDistribution - Domain Services: ReferralChainService, LeaderboardCalculationService - Domain Events: ReferralRelationshipCreated, TeamStatisticsUpdated ## Application Layer - ReferralService: create relationships, get user info, validate codes - TeamStatisticsService: update statistics, get leaderboard, province/city distribution - Commands: CreateReferralRelationship, UpdateTeamStatistics - Queries: GetUserReferralInfo, GetDirectReferrals, GetLeaderboard, GetProvinceCityDistribution - Event Handlers: UserRegisteredHandler, PlantingCreatedHandler ## Infrastructure Layer - Prisma repositories with PostgreSQL - Redis caching for leaderboard - Kafka messaging for domain events - JWT authentication guard ## API Layer - REST endpoints for referral management - Leaderboard endpoints with pagination - Team statistics endpoints - Health check endpoints ## Testing (127 unit + 35 integration + 16 E2E tests) - Domain layer unit tests (100% coverage) - Integration tests with mocks - E2E tests with supertest - Docker test environment with PostgreSQL, Redis, Redpanda 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b350d6b023
commit
7ae98c7f5b
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 应用配置
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 应用配置
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Claude Code local settings
|
||||||
|
.claude/
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Test Dockerfile for referral-service
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy prisma schema
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copy source code and tests
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Set environment for testing
|
||||||
|
ENV NODE_ENV=test
|
||||||
|
|
||||||
|
# Default command - run all tests
|
||||||
|
CMD ["npm", "test"]
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
.PHONY: install build clean test test-unit test-integration test-e2e test-all test-cov \
|
||||||
|
docker-build docker-up docker-down docker-test docker-test-all \
|
||||||
|
prisma-generate prisma-migrate prisma-studio lint format
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 基础命令
|
||||||
|
# ============================================
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
build:
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist node_modules coverage .nyc_output
|
||||||
|
|
||||||
|
lint:
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
format:
|
||||||
|
npm run format
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Prisma 命令
|
||||||
|
# ============================================
|
||||||
|
prisma-generate:
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
prisma-migrate:
|
||||||
|
npx prisma migrate dev
|
||||||
|
|
||||||
|
prisma-migrate-prod:
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
prisma-studio:
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 测试命令
|
||||||
|
# ============================================
|
||||||
|
test: test-unit
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running Unit Tests..."
|
||||||
|
@echo "=========================================="
|
||||||
|
npm test
|
||||||
|
|
||||||
|
test-unit-cov:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running Unit Tests with Coverage..."
|
||||||
|
@echo "=========================================="
|
||||||
|
npm run test:cov
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running Integration Tests..."
|
||||||
|
@echo "=========================================="
|
||||||
|
npm run test:integration
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running E2E Tests..."
|
||||||
|
@echo "=========================================="
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
test-all: test-unit test-integration test-e2e
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "All Tests Completed!"
|
||||||
|
@echo "=========================================="
|
||||||
|
|
||||||
|
test-cov:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running All Tests with Coverage..."
|
||||||
|
@echo "=========================================="
|
||||||
|
npm run test:cov
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Docker 命令
|
||||||
|
# ============================================
|
||||||
|
docker-build:
|
||||||
|
docker build -t referral-service:test -f Dockerfile.test .
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker-compose -f docker-compose.test.yml up -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker-compose -f docker-compose.test.yml down -v
|
||||||
|
|
||||||
|
docker-test-unit:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running Unit Tests in Docker..."
|
||||||
|
@echo "=========================================="
|
||||||
|
docker-compose -f docker-compose.test.yml run --rm test npm test
|
||||||
|
|
||||||
|
docker-test-integration:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running Integration Tests in Docker..."
|
||||||
|
@echo "=========================================="
|
||||||
|
docker-compose -f docker-compose.test.yml run --rm test npm run test:integration
|
||||||
|
|
||||||
|
docker-test-e2e:
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running E2E Tests in Docker..."
|
||||||
|
@echo "=========================================="
|
||||||
|
docker-compose -f docker-compose.test.yml run --rm test npm run test:e2e
|
||||||
|
|
||||||
|
docker-test-all: docker-up
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo "Running All Tests in Docker..."
|
||||||
|
@echo "=========================================="
|
||||||
|
docker-compose -f docker-compose.test.yml run --rm test make test-all
|
||||||
|
$(MAKE) docker-down
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 开发命令
|
||||||
|
# ============================================
|
||||||
|
dev:
|
||||||
|
npm run start:dev
|
||||||
|
|
||||||
|
start:
|
||||||
|
npm run start:prod
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CI/CD 命令
|
||||||
|
# ============================================
|
||||||
|
ci: install lint build test-all
|
||||||
|
@echo "CI Pipeline Completed!"
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL for testing
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: referral_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_test_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Redis for testing
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Kafka for testing (using Redpanda for simplicity)
|
||||||
|
redpanda:
|
||||||
|
image: redpandadata/redpanda:latest
|
||||||
|
command:
|
||||||
|
- redpanda
|
||||||
|
- start
|
||||||
|
- --smp 1
|
||||||
|
- --memory 512M
|
||||||
|
- --reserve-memory 0M
|
||||||
|
- --overprovisioned
|
||||||
|
- --node-id 0
|
||||||
|
- --kafka-addr PLAINTEXT://0.0.0.0:29092,OUTSIDE://0.0.0.0:9093
|
||||||
|
- --advertise-kafka-addr PLAINTEXT://redpanda:29092,OUTSIDE://localhost:9093
|
||||||
|
ports:
|
||||||
|
- "9093:9093"
|
||||||
|
- "29092:29092"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rpk", "cluster", "health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Test runner service
|
||||||
|
test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
redpanda:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: test
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/referral_test?schema=public
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
KAFKA_BROKERS: redpanda:29092
|
||||||
|
JWT_SECRET: test-jwt-secret-for-docker-tests
|
||||||
|
volumes:
|
||||||
|
- ./coverage:/app/coverage
|
||||||
|
command: sh -c "npx prisma migrate deploy && npm run test:cov"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_test_data:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,93 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"test:integration": "jest --config ./test/jest-integration.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": ".",
|
||||||
|
"roots": ["<rootDir>/src/", "<rootDir>/test/"],
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": ["src/**/*.(t|j)s", "!src/main.ts", "!src/**/*.module.ts"],
|
||||||
|
"coverageDirectory": "./coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 完整测试运行脚本
|
||||||
|
# Run all tests for referral-service
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo "Referral Service - Complete Test Suite"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Track test results
|
||||||
|
UNIT_RESULT=0
|
||||||
|
INTEGRATION_RESULT=0
|
||||||
|
E2E_RESULT=0
|
||||||
|
DOCKER_RESULT=0
|
||||||
|
|
||||||
|
# Function to print status
|
||||||
|
print_status() {
|
||||||
|
if [ $1 -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ $2 PASSED${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ $2 FAILED${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Change to project directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
echo "Working directory: $(pwd)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 1. Install dependencies
|
||||||
|
# ============================================
|
||||||
|
echo -e "${YELLOW}Step 1: Installing dependencies...${NC}"
|
||||||
|
npm install
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 2. Generate Prisma client
|
||||||
|
# ============================================
|
||||||
|
echo -e "${YELLOW}Step 2: Generating Prisma client...${NC}"
|
||||||
|
npx prisma generate
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 3. Build the project
|
||||||
|
# ============================================
|
||||||
|
echo -e "${YELLOW}Step 3: Building the project...${NC}"
|
||||||
|
npm run build
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 4. Run Unit Tests
|
||||||
|
# ============================================
|
||||||
|
echo "============================================"
|
||||||
|
echo -e "${YELLOW}Step 4: Running Unit Tests...${NC}"
|
||||||
|
echo "============================================"
|
||||||
|
if npm test; then
|
||||||
|
UNIT_RESULT=0
|
||||||
|
else
|
||||||
|
UNIT_RESULT=1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 5. Run Integration Tests
|
||||||
|
# ============================================
|
||||||
|
echo "============================================"
|
||||||
|
echo -e "${YELLOW}Step 5: Running Integration Tests...${NC}"
|
||||||
|
echo "============================================"
|
||||||
|
if npm run test:integration 2>/dev/null || npm test -- --testRegex="integration.spec.ts$"; then
|
||||||
|
INTEGRATION_RESULT=0
|
||||||
|
else
|
||||||
|
INTEGRATION_RESULT=1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 6. Run E2E Tests
|
||||||
|
# ============================================
|
||||||
|
echo "============================================"
|
||||||
|
echo -e "${YELLOW}Step 6: Running E2E Tests...${NC}"
|
||||||
|
echo "============================================"
|
||||||
|
if npm run test:e2e 2>/dev/null || npm test -- --testRegex="e2e-spec.ts$"; then
|
||||||
|
E2E_RESULT=0
|
||||||
|
else
|
||||||
|
E2E_RESULT=1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 7. Run Docker Tests (optional)
|
||||||
|
# ============================================
|
||||||
|
echo "============================================"
|
||||||
|
echo -e "${YELLOW}Step 7: Running Docker Tests...${NC}"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
if command -v docker &> /dev/null && command -v docker-compose &> /dev/null; then
|
||||||
|
echo "Docker is available, running containerized tests..."
|
||||||
|
|
||||||
|
# Build and run tests in Docker
|
||||||
|
if docker-compose -f docker-compose.test.yml build test && \
|
||||||
|
docker-compose -f docker-compose.test.yml up --abort-on-container-exit test; then
|
||||||
|
DOCKER_RESULT=0
|
||||||
|
else
|
||||||
|
DOCKER_RESULT=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
docker-compose -f docker-compose.test.yml down -v
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Docker not available, skipping Docker tests${NC}"
|
||||||
|
DOCKER_RESULT=-1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Summary
|
||||||
|
# ============================================
|
||||||
|
echo "============================================"
|
||||||
|
echo "TEST SUMMARY"
|
||||||
|
echo "============================================"
|
||||||
|
print_status $UNIT_RESULT "Unit Tests"
|
||||||
|
print_status $INTEGRATION_RESULT "Integration Tests"
|
||||||
|
print_status $E2E_RESULT "E2E Tests"
|
||||||
|
if [ $DOCKER_RESULT -eq -1 ]; then
|
||||||
|
echo -e "${YELLOW}○ Docker Tests SKIPPED${NC}"
|
||||||
|
else
|
||||||
|
print_status $DOCKER_RESULT "Docker Tests"
|
||||||
|
fi
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
# Calculate overall result
|
||||||
|
TOTAL_FAILURES=$((UNIT_RESULT + INTEGRATION_RESULT + E2E_RESULT))
|
||||||
|
if [ $DOCKER_RESULT -gt 0 ]; then
|
||||||
|
TOTAL_FAILURES=$((TOTAL_FAILURES + DOCKER_RESULT))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $TOTAL_FAILURES -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All tests passed!${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some tests failed!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '健康检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务正常' })
|
||||||
|
check(): { status: string; service: string; timestamp: string } {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'referral-service',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
@ApiOperation({ summary: '就绪检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务就绪' })
|
||||||
|
ready(): { status: string } {
|
||||||
|
return { status: 'ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('live')
|
||||||
|
@ApiOperation({ summary: '存活检查' })
|
||||||
|
@ApiResponse({ status: 200, description: '服务存活' })
|
||||||
|
live(): { status: string } {
|
||||||
|
return { status: 'alive' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './referral.controller';
|
||||||
|
export * from './leaderboard.controller';
|
||||||
|
export * from './team-statistics.controller';
|
||||||
|
export * from './health.controller';
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../guards';
|
||||||
|
import { CurrentUser } from '../decorators';
|
||||||
|
import { TeamStatisticsService } from '../../application/services';
|
||||||
|
import {
|
||||||
|
GetLeaderboardDto,
|
||||||
|
LeaderboardResponseDto,
|
||||||
|
UserRankResponseDto,
|
||||||
|
} from '../dto';
|
||||||
|
import { GetLeaderboardQuery } from '../../application/queries';
|
||||||
|
|
||||||
|
@ApiTags('Leaderboard')
|
||||||
|
@Controller('leaderboard')
|
||||||
|
export class LeaderboardController {
|
||||||
|
constructor(private readonly teamStatisticsService: TeamStatisticsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取龙虎榜排名' })
|
||||||
|
@ApiResponse({ status: 200, type: LeaderboardResponseDto })
|
||||||
|
async getLeaderboard(@Query() dto: GetLeaderboardDto): Promise<LeaderboardResponseDto> {
|
||||||
|
const query = new GetLeaderboardQuery(dto.limit, dto.offset);
|
||||||
|
return this.teamStatisticsService.getLeaderboard(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取当前用户龙虎榜排名' })
|
||||||
|
@ApiResponse({ status: 200, type: UserRankResponseDto })
|
||||||
|
async getMyRank(@CurrentUser('userId') userId: bigint): Promise<UserRankResponseDto> {
|
||||||
|
const rank = await this.teamStatisticsService.getUserRank(userId);
|
||||||
|
const leaderboard = await this.teamStatisticsService.getLeaderboard(
|
||||||
|
new GetLeaderboardQuery(1000, 0),
|
||||||
|
);
|
||||||
|
const userEntry = leaderboard.entries.find((e) => e.userId === userId.toString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: userId.toString(),
|
||||||
|
rank,
|
||||||
|
score: userEntry?.score ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('user/:userId')
|
||||||
|
@ApiOperation({ summary: '获取指定用户龙虎榜排名' })
|
||||||
|
@ApiParam({ name: 'userId', description: '用户ID' })
|
||||||
|
@ApiResponse({ status: 200, type: UserRankResponseDto })
|
||||||
|
async getUserRank(@Param('userId') userId: string): Promise<UserRankResponseDto> {
|
||||||
|
const userIdBigInt = BigInt(userId);
|
||||||
|
const rank = await this.teamStatisticsService.getUserRank(userIdBigInt);
|
||||||
|
const leaderboard = await this.teamStatisticsService.getLeaderboard(
|
||||||
|
new GetLeaderboardQuery(1000, 0),
|
||||||
|
);
|
||||||
|
const userEntry = leaderboard.entries.find((e) => e.userId === userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
rank,
|
||||||
|
score: userEntry?.score ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../guards';
|
||||||
|
import { CurrentUser } from '../decorators';
|
||||||
|
import { ReferralService } from '../../application/services';
|
||||||
|
import {
|
||||||
|
ValidateReferralCodeDto,
|
||||||
|
GetDirectReferralsDto,
|
||||||
|
ReferralInfoResponseDto,
|
||||||
|
DirectReferralsResponseDto,
|
||||||
|
ValidateCodeResponseDto,
|
||||||
|
CreateReferralDto,
|
||||||
|
} from '../dto';
|
||||||
|
import { CreateReferralRelationshipCommand } from '../../application/commands';
|
||||||
|
import { GetUserReferralInfoQuery, GetDirectReferralsQuery } from '../../application/queries';
|
||||||
|
|
||||||
|
@ApiTags('Referral')
|
||||||
|
@Controller('referral')
|
||||||
|
export class ReferralController {
|
||||||
|
constructor(private readonly referralService: ReferralService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取当前用户推荐信息' })
|
||||||
|
@ApiResponse({ status: 200, type: ReferralInfoResponseDto })
|
||||||
|
async getMyReferralInfo(@CurrentUser('userId') userId: bigint): Promise<ReferralInfoResponseDto> {
|
||||||
|
const query = new GetUserReferralInfoQuery(userId);
|
||||||
|
return this.referralService.getUserReferralInfo(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/direct-referrals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取当前用户直推列表' })
|
||||||
|
@ApiResponse({ status: 200, type: DirectReferralsResponseDto })
|
||||||
|
async getMyDirectReferrals(
|
||||||
|
@CurrentUser('userId') userId: bigint,
|
||||||
|
@Query() dto: GetDirectReferralsDto,
|
||||||
|
): Promise<DirectReferralsResponseDto> {
|
||||||
|
const query = new GetDirectReferralsQuery(userId, dto.limit, dto.offset);
|
||||||
|
return this.referralService.getDirectReferrals(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('validate/:code')
|
||||||
|
@ApiOperation({ summary: '验证推荐码是否有效' })
|
||||||
|
@ApiParam({ name: 'code', description: '推荐码' })
|
||||||
|
@ApiResponse({ status: 200, type: ValidateCodeResponseDto })
|
||||||
|
async validateCode(@Param('code') code: string): Promise<ValidateCodeResponseDto> {
|
||||||
|
const referrer = await this.referralService.getReferrerByCode(code.toUpperCase());
|
||||||
|
return {
|
||||||
|
valid: referrer !== null,
|
||||||
|
referrerId: referrer?.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('validate')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '验证推荐码 (POST方式)' })
|
||||||
|
@ApiResponse({ status: 200, type: ValidateCodeResponseDto })
|
||||||
|
async validateCodePost(@Body() dto: ValidateReferralCodeDto): Promise<ValidateCodeResponseDto> {
|
||||||
|
const referrer = await this.referralService.getReferrerByCode(dto.code.toUpperCase());
|
||||||
|
return {
|
||||||
|
valid: referrer !== null,
|
||||||
|
referrerId: referrer?.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建推荐关系 (内部接口)' })
|
||||||
|
@ApiResponse({ status: 201, description: '创建成功' })
|
||||||
|
async createReferralRelationship(
|
||||||
|
@Body() dto: CreateReferralDto,
|
||||||
|
): Promise<{ referralCode: string }> {
|
||||||
|
const command = new CreateReferralRelationshipCommand(
|
||||||
|
BigInt(dto.userId),
|
||||||
|
dto.referrerCode ?? null,
|
||||||
|
);
|
||||||
|
return this.referralService.createReferralRelationship(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('user/:userId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取指定用户推荐信息' })
|
||||||
|
@ApiParam({ name: 'userId', description: '用户ID' })
|
||||||
|
@ApiResponse({ status: 200, type: ReferralInfoResponseDto })
|
||||||
|
async getUserReferralInfo(@Param('userId') userId: string): Promise<ReferralInfoResponseDto> {
|
||||||
|
const query = new GetUserReferralInfoQuery(BigInt(userId));
|
||||||
|
return this.referralService.getUserReferralInfo(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../guards';
|
||||||
|
import { CurrentUser } from '../decorators';
|
||||||
|
import { TeamStatisticsService } from '../../application/services';
|
||||||
|
import { ProvinceCityDistributionResponseDto } from '../dto';
|
||||||
|
import { GetProvinceCityDistributionQuery } from '../../application/queries';
|
||||||
|
|
||||||
|
@ApiTags('Team Statistics')
|
||||||
|
@Controller('team-statistics')
|
||||||
|
export class TeamStatisticsController {
|
||||||
|
constructor(private readonly teamStatisticsService: TeamStatisticsService) {}
|
||||||
|
|
||||||
|
@Get('me/distribution')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '获取当前用户团队省市分布' })
|
||||||
|
@ApiResponse({ status: 200, type: ProvinceCityDistributionResponseDto })
|
||||||
|
async getMyDistribution(
|
||||||
|
@CurrentUser('userId') userId: bigint,
|
||||||
|
): Promise<ProvinceCityDistributionResponseDto> {
|
||||||
|
const query = new GetProvinceCityDistributionQuery(userId);
|
||||||
|
return this.teamStatisticsService.getProvinceCityDistribution(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthenticatedRequest } from '../guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: keyof AuthenticatedRequest['user'] | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './current-user.decorator';
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './referral.dto';
|
||||||
|
export * from './leaderboard.dto';
|
||||||
|
export * from './team-statistics.dto';
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { IsInt, Min, Max, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class GetLeaderboardDto {
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 100;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '偏移量', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
offset?: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaderboardEntryResponseDto {
|
||||||
|
@ApiProperty({ description: '排名' })
|
||||||
|
rank: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '龙虎榜分值' })
|
||||||
|
score: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队总认种量' })
|
||||||
|
totalTeamCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直推人数' })
|
||||||
|
directReferralCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaderboardResponseDto {
|
||||||
|
@ApiProperty({ type: [LeaderboardEntryResponseDto] })
|
||||||
|
entries: LeaderboardEntryResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否有更多' })
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserRankResponseDto {
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '排名', nullable: true })
|
||||||
|
rank: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '龙虎榜分值' })
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { IsString, IsOptional, Length, Matches, IsInt, Min, Max } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ValidateReferralCodeDto {
|
||||||
|
@ApiProperty({ description: '推荐码', example: 'RWA123ABC' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 20)
|
||||||
|
@Matches(/^[A-Z0-9]+$/, { message: '推荐码只能包含大写字母和数字' })
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateReferralDto {
|
||||||
|
@ApiProperty({ description: '用户ID', example: '123456789' })
|
||||||
|
@IsString()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '推荐码', example: 'RWA123ABC' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 20)
|
||||||
|
referrerCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetDirectReferralsDto {
|
||||||
|
@ApiPropertyOptional({ description: '每页数量', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 50;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '偏移量', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
offset?: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReferralInfoResponseDto {
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '推荐码' })
|
||||||
|
referralCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '推荐人ID', nullable: true })
|
||||||
|
referrerId: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '推荐链深度' })
|
||||||
|
referralChainDepth: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直推人数' })
|
||||||
|
directReferralCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队总认种量' })
|
||||||
|
totalTeamCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '个人认种量' })
|
||||||
|
personalPlantingCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队认种量' })
|
||||||
|
teamPlantingCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '龙虎榜分值' })
|
||||||
|
leaderboardScore: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '龙虎榜排名', nullable: true })
|
||||||
|
leaderboardRank: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DirectReferralResponseDto {
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '推荐码' })
|
||||||
|
referralCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队认种量' })
|
||||||
|
teamCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '加入时间' })
|
||||||
|
joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DirectReferralsResponseDto {
|
||||||
|
@ApiProperty({ type: [DirectReferralResponseDto] })
|
||||||
|
referrals: DirectReferralResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否有更多' })
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateCodeResponseDto {
|
||||||
|
@ApiProperty({ description: '是否有效' })
|
||||||
|
valid: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '推荐人ID' })
|
||||||
|
referrerId?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CityDistributionDto {
|
||||||
|
@ApiProperty({ description: '城市代码' })
|
||||||
|
cityCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '认种数量' })
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProvinceDistributionDto {
|
||||||
|
@ApiProperty({ description: '省份代码' })
|
||||||
|
provinceCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '省份总认种量' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CityDistributionDto] })
|
||||||
|
cities: CityDistributionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProvinceCityDistributionResponseDto {
|
||||||
|
@ApiProperty({ type: [ProvinceDistributionDto] })
|
||||||
|
provinces: ProvinceDistributionDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总认种量' })
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamStatisticsResponseDto {
|
||||||
|
@ApiProperty({ description: '用户ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '直推人数' })
|
||||||
|
directReferralCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队总认种量' })
|
||||||
|
totalTeamCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '个人认种量' })
|
||||||
|
personalPlantingCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '团队认种量' })
|
||||||
|
teamPlantingCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '龙虎榜分值' })
|
||||||
|
leaderboardScore: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '最大单直推团队认种量' })
|
||||||
|
maxDirectTeamCount: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './jwt-auth.guard';
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
userId: string;
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest {
|
||||||
|
user: {
|
||||||
|
userId: bigint;
|
||||||
|
sub: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||||
|
private readonly jwtSecret: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.jwtSecret = this.configService.get<string>('JWT_SECRET', 'default-secret-change-in-production');
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Missing authentication token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, this.jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
|
if (payload.type !== 'access') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = {
|
||||||
|
userId: BigInt(payload.userId),
|
||||||
|
sub: payload.sub,
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`JWT verification failed: ${error}`);
|
||||||
|
throw new UnauthorizedException('Invalid authentication token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: { headers: { authorization?: string } }): string | null {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader) return null;
|
||||||
|
|
||||||
|
const [type, token] = authHeader.split(' ');
|
||||||
|
return type === 'Bearer' ? token : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
|
export * from './guards';
|
||||||
|
export * from './decorators';
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ApiModule } from './modules';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env.development', '.env'],
|
||||||
|
}),
|
||||||
|
ApiModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class CreateReferralRelationshipCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: bigint,
|
||||||
|
public readonly referrerCode: string | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './create-referral-relationship.command';
|
||||||
|
export * from './update-team-statistics.command';
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export class UpdateTeamStatisticsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: bigint,
|
||||||
|
public readonly plantingCount: number,
|
||||||
|
public readonly provinceCode: string,
|
||||||
|
public readonly cityCode: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './user-registered.handler';
|
||||||
|
export * from './planting-created.handler';
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { KafkaService } from '../../infrastructure';
|
||||||
|
import { TeamStatisticsService } from '../services';
|
||||||
|
import { UpdateTeamStatisticsCommand } from '../commands';
|
||||||
|
|
||||||
|
interface PlantingCreatedEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种创建事件处理器
|
||||||
|
* 监听 planting-service 发出的认种事件
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PlantingCreatedHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly kafkaService: KafkaService,
|
||||||
|
private readonly teamStatisticsService: TeamStatisticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-planting-created',
|
||||||
|
['planting.planting.created'],
|
||||||
|
this.handleMessage.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log('Subscribed to planting.created events');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(topic: string, message: Record<string, unknown>): Promise<void> {
|
||||||
|
const event = message as unknown as PlantingCreatedEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'planting.created') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new UpdateTeamStatisticsCommand(
|
||||||
|
BigInt(event.data.userId),
|
||||||
|
event.data.treeCount,
|
||||||
|
event.data.provinceCode,
|
||||||
|
event.data.cityCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.teamStatisticsService.handlePlantingEvent(command);
|
||||||
|
this.logger.log(
|
||||||
|
`Updated team statistics for user ${event.data.userId}, count: ${event.data.treeCount}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to update team statistics for user ${event.data.userId}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { KafkaService } from '../../infrastructure';
|
||||||
|
import { ReferralService } from '../services';
|
||||||
|
import { CreateReferralRelationshipCommand } from '../commands';
|
||||||
|
|
||||||
|
interface UserRegisteredEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
referralCode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册事件处理器
|
||||||
|
* 监听 identity-service 发出的用户注册事件
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UserRegisteredHandler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(UserRegisteredHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly kafkaService: KafkaService,
|
||||||
|
private readonly referralService: ReferralService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-user-registered',
|
||||||
|
['identity.user.registered'],
|
||||||
|
this.handleMessage.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log('Subscribed to user.registered events');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(topic: string, message: Record<string, unknown>): Promise<void> {
|
||||||
|
const event = message as unknown as UserRegisteredEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'user.registered') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateReferralRelationshipCommand(
|
||||||
|
BigInt(event.data.userId),
|
||||||
|
event.data.referralCode ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await this.referralService.createReferralRelationship(command);
|
||||||
|
this.logger.log(
|
||||||
|
`Created referral relationship for user ${event.data.userId}, code: ${result.referralCode}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to create referral relationship for user ${event.data.userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './commands';
|
||||||
|
export * from './queries';
|
||||||
|
export * from './services';
|
||||||
|
export * from './event-handlers';
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
export class GetDirectReferralsQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: bigint,
|
||||||
|
public readonly limit: number = 50,
|
||||||
|
public readonly offset: number = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectReferralResult {
|
||||||
|
userId: string;
|
||||||
|
referralCode: string;
|
||||||
|
teamCount: number;
|
||||||
|
joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectReferralsResult {
|
||||||
|
referrals: DirectReferralResult[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
export class GetLeaderboardQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly limit: number = 100,
|
||||||
|
public readonly offset: number = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntryResult {
|
||||||
|
rank: number;
|
||||||
|
userId: string;
|
||||||
|
score: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
directReferralCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardResult {
|
||||||
|
entries: LeaderboardEntryResult[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
export class GetProvinceCityDistributionQuery {
|
||||||
|
constructor(public readonly userId: bigint) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvinceCityDistributionResult {
|
||||||
|
provinces: Array<{
|
||||||
|
provinceCode: string;
|
||||||
|
total: number;
|
||||||
|
cities: Array<{
|
||||||
|
cityCode: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export class GetUserReferralInfoQuery {
|
||||||
|
constructor(public readonly userId: bigint) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserReferralInfoResult {
|
||||||
|
userId: string;
|
||||||
|
referralCode: string;
|
||||||
|
referrerId: string | null;
|
||||||
|
referralChainDepth: number;
|
||||||
|
directReferralCount: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
personalPlantingCount: number;
|
||||||
|
teamPlantingCount: number;
|
||||||
|
leaderboardScore: number;
|
||||||
|
leaderboardRank: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './get-user-referral-info.query';
|
||||||
|
export * from './get-leaderboard.query';
|
||||||
|
export * from './get-direct-referrals.query';
|
||||||
|
export * from './get-province-city-distribution.query';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './referral.service';
|
||||||
|
export * from './team-statistics.service';
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
IReferralRelationshipRepository,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
ITeamStatisticsRepository,
|
||||||
|
ReferralRelationship,
|
||||||
|
ReferralChainService,
|
||||||
|
} from '../../domain';
|
||||||
|
import { EventPublisherService, LeaderboardCacheService } from '../../infrastructure';
|
||||||
|
import { CreateReferralRelationshipCommand } from '../commands';
|
||||||
|
import {
|
||||||
|
GetUserReferralInfoQuery,
|
||||||
|
UserReferralInfoResult,
|
||||||
|
GetDirectReferralsQuery,
|
||||||
|
DirectReferralsResult,
|
||||||
|
} from '../queries';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReferralService {
|
||||||
|
private readonly logger = new Logger(ReferralService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
|
||||||
|
private readonly referralRepo: IReferralRelationshipRepository,
|
||||||
|
@Inject(TEAM_STATISTICS_REPOSITORY)
|
||||||
|
private readonly teamStatsRepo: ITeamStatisticsRepository,
|
||||||
|
private readonly referralChainService: ReferralChainService,
|
||||||
|
private readonly eventPublisher: EventPublisherService,
|
||||||
|
private readonly leaderboardCache: LeaderboardCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建推荐关系 (用户注册时调用)
|
||||||
|
*/
|
||||||
|
async createReferralRelationship(
|
||||||
|
command: CreateReferralRelationshipCommand,
|
||||||
|
): Promise<{ referralCode: string }> {
|
||||||
|
// 检查用户是否已有推荐关系
|
||||||
|
const exists = await this.referralRepo.existsByUserId(command.userId);
|
||||||
|
if (exists) {
|
||||||
|
throw new BadRequestException('用户已存在推荐关系');
|
||||||
|
}
|
||||||
|
|
||||||
|
let referrerId: bigint | null = null;
|
||||||
|
let parentChain: bigint[] = [];
|
||||||
|
|
||||||
|
// 如果有推荐码,查找推荐人
|
||||||
|
if (command.referrerCode) {
|
||||||
|
const referrer = await this.referralRepo.findByReferralCode(command.referrerCode);
|
||||||
|
if (!referrer) {
|
||||||
|
throw new NotFoundException('推荐码不存在');
|
||||||
|
}
|
||||||
|
referrerId = referrer.userId;
|
||||||
|
parentChain = referrer.referralChain;
|
||||||
|
|
||||||
|
// 验证推荐链
|
||||||
|
if (!this.referralChainService.validateChain(parentChain, command.userId)) {
|
||||||
|
throw new BadRequestException('无效的推荐关系');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建推荐关系
|
||||||
|
const relationship = ReferralRelationship.create(command.userId, referrerId, parentChain);
|
||||||
|
const saved = await this.referralRepo.save(relationship);
|
||||||
|
|
||||||
|
// 创建团队统计记录
|
||||||
|
await this.teamStatsRepo.create(command.userId);
|
||||||
|
|
||||||
|
// 如果有推荐人,更新推荐人的直推计数
|
||||||
|
if (referrerId) {
|
||||||
|
const referrerStats = await this.teamStatsRepo.findByUserId(referrerId);
|
||||||
|
if (referrerStats) {
|
||||||
|
referrerStats.addDirectReferral(command.userId);
|
||||||
|
await this.teamStatsRepo.save(referrerStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布领域事件
|
||||||
|
await this.eventPublisher.publishDomainEvents(saved.domainEvents);
|
||||||
|
saved.clearDomainEvents();
|
||||||
|
|
||||||
|
this.logger.log(`Created referral relationship for user ${command.userId}`);
|
||||||
|
|
||||||
|
return { referralCode: saved.referralCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户推荐信息
|
||||||
|
*/
|
||||||
|
async getUserReferralInfo(query: GetUserReferralInfoQuery): Promise<UserReferralInfoResult> {
|
||||||
|
const relationship = await this.referralRepo.findByUserId(query.userId);
|
||||||
|
if (!relationship) {
|
||||||
|
throw new NotFoundException('用户推荐关系不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamStats = await this.teamStatsRepo.findByUserId(query.userId);
|
||||||
|
const rank = await this.leaderboardCache.getUserRank(query.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: relationship.userId.toString(),
|
||||||
|
referralCode: relationship.referralCode,
|
||||||
|
referrerId: relationship.referrerId?.toString() ?? null,
|
||||||
|
referralChainDepth: relationship.getChainDepth(),
|
||||||
|
directReferralCount: teamStats?.directReferralCount ?? 0,
|
||||||
|
totalTeamCount: teamStats?.totalTeamCount ?? 0,
|
||||||
|
personalPlantingCount: teamStats?.personalPlantingCount ?? 0,
|
||||||
|
teamPlantingCount: teamStats?.teamPlantingCount ?? 0,
|
||||||
|
leaderboardScore: teamStats?.leaderboardScore ?? 0,
|
||||||
|
leaderboardRank: rank,
|
||||||
|
createdAt: relationship.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据推荐码获取推荐人信息
|
||||||
|
*/
|
||||||
|
async getReferrerByCode(code: string): Promise<{ userId: string; referralCode: string } | null> {
|
||||||
|
const relationship = await this.referralRepo.findByReferralCode(code);
|
||||||
|
if (!relationship) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: relationship.userId.toString(),
|
||||||
|
referralCode: relationship.referralCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直推列表
|
||||||
|
*/
|
||||||
|
async getDirectReferrals(query: GetDirectReferralsQuery): Promise<DirectReferralsResult> {
|
||||||
|
const relationships = await this.referralRepo.findDirectReferrals(query.userId);
|
||||||
|
const teamStats = await this.teamStatsRepo.findByUserId(query.userId);
|
||||||
|
const directStats = teamStats?.getDirectReferralStats() ?? new Map();
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const total = relationships.length;
|
||||||
|
const paginated = relationships.slice(query.offset, query.offset + query.limit);
|
||||||
|
|
||||||
|
const referrals = paginated.map((r) => ({
|
||||||
|
userId: r.userId.toString(),
|
||||||
|
referralCode: r.referralCode,
|
||||||
|
teamCount: directStats.get(r.userId) ?? 0,
|
||||||
|
joinedAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
referrals,
|
||||||
|
total,
|
||||||
|
hasMore: query.offset + query.limit < total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查推荐码是否有效
|
||||||
|
*/
|
||||||
|
async validateReferralCode(code: string): Promise<boolean> {
|
||||||
|
return this.referralRepo.existsByReferralCode(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
IReferralRelationshipRepository,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
ITeamStatisticsRepository,
|
||||||
|
ReferralChainService,
|
||||||
|
} from '../../domain';
|
||||||
|
import { EventPublisherService, LeaderboardCacheService } from '../../infrastructure';
|
||||||
|
import { UpdateTeamStatisticsCommand } from '../commands';
|
||||||
|
import {
|
||||||
|
GetLeaderboardQuery,
|
||||||
|
LeaderboardResult,
|
||||||
|
GetProvinceCityDistributionQuery,
|
||||||
|
ProvinceCityDistributionResult,
|
||||||
|
} from '../queries';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamStatisticsService {
|
||||||
|
private readonly logger = new Logger(TeamStatisticsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
|
||||||
|
private readonly referralRepo: IReferralRelationshipRepository,
|
||||||
|
@Inject(TEAM_STATISTICS_REPOSITORY)
|
||||||
|
private readonly teamStatsRepo: ITeamStatisticsRepository,
|
||||||
|
private readonly referralChainService: ReferralChainService,
|
||||||
|
private readonly eventPublisher: EventPublisherService,
|
||||||
|
private readonly leaderboardCache: LeaderboardCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认种事件 - 更新用户及其所有上级的团队统计
|
||||||
|
*/
|
||||||
|
async handlePlantingEvent(command: UpdateTeamStatisticsCommand): Promise<void> {
|
||||||
|
// 获取用户的推荐关系
|
||||||
|
const relationship = await this.referralRepo.findByUserId(command.userId);
|
||||||
|
if (!relationship) {
|
||||||
|
this.logger.warn(`User ${command.userId} has no referral relationship`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户自己的个人认种统计
|
||||||
|
const userStats = await this.teamStatsRepo.findByUserId(command.userId);
|
||||||
|
if (userStats) {
|
||||||
|
userStats.addPersonalPlanting(command.plantingCount, command.provinceCode, command.cityCode);
|
||||||
|
await this.teamStatsRepo.save(userStats);
|
||||||
|
await this.leaderboardCache.updateScore(command.userId, userStats.leaderboardScore);
|
||||||
|
await this.eventPublisher.publishDomainEvents(userStats.domainEvents);
|
||||||
|
userStats.clearDomainEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有需要更新的上级
|
||||||
|
const ancestors = relationship.getAllAncestorIds();
|
||||||
|
if (ancestors.length === 0) return;
|
||||||
|
|
||||||
|
// 准备批量更新数据
|
||||||
|
const updates: Array<{
|
||||||
|
userId: bigint;
|
||||||
|
countDelta: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
fromDirectReferralId?: bigint;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 第一级上级 (直接推荐人) 的 fromDirectReferralId 是当前用户
|
||||||
|
// 第二级及以上的 fromDirectReferralId 是第一级上级
|
||||||
|
let currentSource = command.userId;
|
||||||
|
|
||||||
|
for (let i = 0; i < ancestors.length; i++) {
|
||||||
|
const ancestorId = ancestors[i];
|
||||||
|
updates.push({
|
||||||
|
userId: ancestorId,
|
||||||
|
countDelta: command.plantingCount,
|
||||||
|
provinceCode: command.provinceCode,
|
||||||
|
cityCode: command.cityCode,
|
||||||
|
fromDirectReferralId: i === 0 ? command.userId : ancestors[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新
|
||||||
|
await this.teamStatsRepo.batchUpdateTeamCounts(updates);
|
||||||
|
|
||||||
|
// 更新排行榜缓存
|
||||||
|
for (const ancestorId of ancestors) {
|
||||||
|
const stats = await this.teamStatsRepo.findByUserId(ancestorId);
|
||||||
|
if (stats) {
|
||||||
|
await this.leaderboardCache.updateScore(ancestorId, stats.leaderboardScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Updated team statistics for ${ancestors.length} ancestors of user ${command.userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取龙虎榜
|
||||||
|
*/
|
||||||
|
async getLeaderboard(query: GetLeaderboardQuery): Promise<LeaderboardResult> {
|
||||||
|
// 尝试从缓存获取
|
||||||
|
const cachedEntries = await this.leaderboardCache.getTopN(query.limit);
|
||||||
|
if (cachedEntries.length > 0) {
|
||||||
|
// 补充完整数据
|
||||||
|
const userIds = cachedEntries.map((e) => e.userId);
|
||||||
|
const statsMap = new Map<string, { totalTeamCount: number; directReferralCount: number }>();
|
||||||
|
const stats = await this.teamStatsRepo.findByUserIds(userIds);
|
||||||
|
stats.forEach((s) => {
|
||||||
|
statsMap.set(s.userId.toString(), {
|
||||||
|
totalTeamCount: s.totalTeamCount,
|
||||||
|
directReferralCount: s.directReferralCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = cachedEntries.map((e) => {
|
||||||
|
const extra = statsMap.get(e.userId.toString());
|
||||||
|
return {
|
||||||
|
rank: e.rank,
|
||||||
|
userId: e.userId.toString(),
|
||||||
|
score: e.score,
|
||||||
|
totalTeamCount: extra?.totalTeamCount ?? 0,
|
||||||
|
directReferralCount: extra?.directReferralCount ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
total: entries.length,
|
||||||
|
hasMore: false, // 缓存暂不支持分页
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库获取
|
||||||
|
const dbEntries = await this.teamStatsRepo.getLeaderboard({
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = dbEntries.map((e) => ({
|
||||||
|
rank: e.rank,
|
||||||
|
userId: e.userId.toString(),
|
||||||
|
score: e.score,
|
||||||
|
totalTeamCount: e.totalTeamCount,
|
||||||
|
directReferralCount: e.directReferralCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
total: entries.length,
|
||||||
|
hasMore: entries.length === query.limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户排名
|
||||||
|
*/
|
||||||
|
async getUserRank(userId: bigint): Promise<number | null> {
|
||||||
|
// 先尝试缓存
|
||||||
|
const cachedRank = await this.leaderboardCache.getUserRank(userId);
|
||||||
|
if (cachedRank !== null) {
|
||||||
|
return cachedRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库获取
|
||||||
|
return this.teamStatsRepo.getUserRank(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取省市分布统计
|
||||||
|
*/
|
||||||
|
async getProvinceCityDistribution(
|
||||||
|
query: GetProvinceCityDistributionQuery,
|
||||||
|
): Promise<ProvinceCityDistributionResult> {
|
||||||
|
const stats = await this.teamStatsRepo.findByUserId(query.userId);
|
||||||
|
if (!stats) {
|
||||||
|
return {
|
||||||
|
provinces: [],
|
||||||
|
totalCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const distribution = stats.provinceCityDistribution;
|
||||||
|
const allData = distribution.getAll();
|
||||||
|
|
||||||
|
// 按省分组
|
||||||
|
const provinceMap = new Map<string, { total: number; cities: Map<string, number> }>();
|
||||||
|
for (const item of allData) {
|
||||||
|
if (!provinceMap.has(item.provinceCode)) {
|
||||||
|
provinceMap.set(item.provinceCode, { total: 0, cities: new Map() });
|
||||||
|
}
|
||||||
|
const province = provinceMap.get(item.provinceCode)!;
|
||||||
|
province.total += item.count;
|
||||||
|
province.cities.set(item.cityCode, item.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinces = Array.from(provinceMap.entries()).map(([provinceCode, data]) => ({
|
||||||
|
provinceCode,
|
||||||
|
total: data.total,
|
||||||
|
cities: Array.from(data.cities.entries()).map(([cityCode, count]) => ({
|
||||||
|
cityCode,
|
||||||
|
count,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
provinces,
|
||||||
|
totalCount: distribution.getTotal(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './referral-relationship';
|
||||||
|
export * from './team-statistics';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './referral-relationship.aggregate';
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { ReferralCode, ReferralChain, UserId } from '../../value-objects';
|
||||||
|
import { DomainEvent, ReferralRelationshipCreatedEvent } from '../../events';
|
||||||
|
|
||||||
|
export interface ReferralRelationshipProps {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
referrerId: bigint | null;
|
||||||
|
referralCode: string;
|
||||||
|
referralChain: bigint[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐关系聚合根
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 管理用户之间的推荐关系
|
||||||
|
* - 生成和验证推荐码
|
||||||
|
* - 维护推荐链
|
||||||
|
*/
|
||||||
|
export class ReferralRelationship {
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly _id: bigint,
|
||||||
|
private readonly _userId: UserId,
|
||||||
|
private readonly _referrerId: UserId | null,
|
||||||
|
private readonly _referralCode: ReferralCode,
|
||||||
|
private readonly _referralChain: ReferralChain,
|
||||||
|
private readonly _createdAt: Date,
|
||||||
|
private _updatedAt: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
get userId(): bigint {
|
||||||
|
return this._userId.value;
|
||||||
|
}
|
||||||
|
get referrerId(): bigint | null {
|
||||||
|
return this._referrerId?.value ?? null;
|
||||||
|
}
|
||||||
|
get referralCode(): string {
|
||||||
|
return this._referralCode.value;
|
||||||
|
}
|
||||||
|
get referralChain(): bigint[] {
|
||||||
|
return this._referralChain.toArray();
|
||||||
|
}
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this._createdAt;
|
||||||
|
}
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
get domainEvents(): DomainEvent[] {
|
||||||
|
return [...this._domainEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的推荐关系 (用户注册时)
|
||||||
|
*/
|
||||||
|
static create(
|
||||||
|
userId: bigint,
|
||||||
|
referrerId: bigint | null,
|
||||||
|
parentReferralChain: bigint[] = [],
|
||||||
|
): ReferralRelationship {
|
||||||
|
const userIdVo = UserId.create(userId);
|
||||||
|
const referrerIdVo = referrerId ? UserId.create(referrerId) : null;
|
||||||
|
const referralCode = ReferralCode.generate(userId);
|
||||||
|
const referralChain = ReferralChain.create(referrerId, parentReferralChain);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const relationship = new ReferralRelationship(
|
||||||
|
0n, // ID will be assigned by database
|
||||||
|
userIdVo,
|
||||||
|
referrerIdVo,
|
||||||
|
referralCode,
|
||||||
|
referralChain,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发布领域事件
|
||||||
|
relationship._domainEvents.push(
|
||||||
|
new ReferralRelationshipCreatedEvent(
|
||||||
|
userId,
|
||||||
|
referrerId,
|
||||||
|
referralCode.value,
|
||||||
|
referralChain.toArray(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(props: ReferralRelationshipProps): ReferralRelationship {
|
||||||
|
return new ReferralRelationship(
|
||||||
|
props.id,
|
||||||
|
UserId.create(props.userId),
|
||||||
|
props.referrerId ? UserId.create(props.referrerId) : null,
|
||||||
|
ReferralCode.create(props.referralCode),
|
||||||
|
ReferralChain.fromArray(props.referralChain),
|
||||||
|
props.createdAt,
|
||||||
|
props.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直接推荐人
|
||||||
|
*/
|
||||||
|
getDirectReferrer(): bigint | null {
|
||||||
|
return this._referralChain.directReferrer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定层级的上级
|
||||||
|
*/
|
||||||
|
getReferrerAtLevel(level: number): bigint | null {
|
||||||
|
return this._referralChain.getReferrerAtLevel(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有上级ID (用于团队统计更新)
|
||||||
|
*/
|
||||||
|
getAllAncestorIds(): bigint[] {
|
||||||
|
return this._referralChain.getAllAncestors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐链深度
|
||||||
|
*/
|
||||||
|
getChainDepth(): number {
|
||||||
|
return this._referralChain.depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除领域事件
|
||||||
|
*/
|
||||||
|
clearDomainEvents(): void {
|
||||||
|
this._domainEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为持久化格式
|
||||||
|
*/
|
||||||
|
toPersistence(): ReferralRelationshipProps {
|
||||||
|
return {
|
||||||
|
id: this._id,
|
||||||
|
userId: this._userId.value,
|
||||||
|
referrerId: this._referrerId?.value ?? null,
|
||||||
|
referralCode: this._referralCode.value,
|
||||||
|
referralChain: this._referralChain.toArray(),
|
||||||
|
createdAt: this._createdAt,
|
||||||
|
updatedAt: this._updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './team-statistics.aggregate';
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { UserId, LeaderboardScore, ProvinceCityDistribution } from '../../value-objects';
|
||||||
|
import { DomainEvent, TeamStatisticsUpdatedEvent } from '../../events';
|
||||||
|
|
||||||
|
export interface DirectReferralStats {
|
||||||
|
referralId: bigint;
|
||||||
|
teamCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamStatisticsProps {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
directReferralCount: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
personalPlantingCount: number;
|
||||||
|
teamPlantingCount: number;
|
||||||
|
leaderboardScore: number;
|
||||||
|
maxDirectTeamCount: number;
|
||||||
|
provinceCityDistribution: Record<string, Record<string, number>> | null;
|
||||||
|
lastCalculatedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
directReferrals?: DirectReferralStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队统计聚合根
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 维护用户的团队统计数据
|
||||||
|
* - 计算龙虎榜分值
|
||||||
|
* - 跟踪省市认种分布
|
||||||
|
*/
|
||||||
|
export class TeamStatistics {
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly _id: bigint,
|
||||||
|
private readonly _userId: UserId,
|
||||||
|
private _directReferralCount: number,
|
||||||
|
private _totalTeamCount: number,
|
||||||
|
private _personalPlantingCount: number,
|
||||||
|
private _teamPlantingCount: number,
|
||||||
|
private _leaderboardScore: LeaderboardScore,
|
||||||
|
private _provinceCityDistribution: ProvinceCityDistribution,
|
||||||
|
private _lastCalculatedAt: Date,
|
||||||
|
private readonly _createdAt: Date,
|
||||||
|
private _updatedAt: Date,
|
||||||
|
private _directReferralStats: Map<bigint, number> = new Map(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): bigint {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
get userId(): bigint {
|
||||||
|
return this._userId.value;
|
||||||
|
}
|
||||||
|
get directReferralCount(): number {
|
||||||
|
return this._directReferralCount;
|
||||||
|
}
|
||||||
|
get totalTeamCount(): number {
|
||||||
|
return this._totalTeamCount;
|
||||||
|
}
|
||||||
|
get personalPlantingCount(): number {
|
||||||
|
return this._personalPlantingCount;
|
||||||
|
}
|
||||||
|
get teamPlantingCount(): number {
|
||||||
|
return this._teamPlantingCount;
|
||||||
|
}
|
||||||
|
get leaderboardScore(): number {
|
||||||
|
return this._leaderboardScore.score;
|
||||||
|
}
|
||||||
|
get maxDirectTeamCount(): number {
|
||||||
|
return this._leaderboardScore.maxDirectTeamCount;
|
||||||
|
}
|
||||||
|
get provinceCityDistribution(): ProvinceCityDistribution {
|
||||||
|
return this._provinceCityDistribution;
|
||||||
|
}
|
||||||
|
get lastCalculatedAt(): Date {
|
||||||
|
return this._lastCalculatedAt;
|
||||||
|
}
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this._createdAt;
|
||||||
|
}
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
get domainEvents(): DomainEvent[] {
|
||||||
|
return [...this._domainEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的团队统计记录 (推荐关系创建时)
|
||||||
|
*/
|
||||||
|
static create(userId: bigint): TeamStatistics {
|
||||||
|
const now = new Date();
|
||||||
|
return new TeamStatistics(
|
||||||
|
0n,
|
||||||
|
UserId.create(userId),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
LeaderboardScore.zero(),
|
||||||
|
ProvinceCityDistribution.empty(),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(props: TeamStatisticsProps): TeamStatistics {
|
||||||
|
const directTeamCounts = props.directReferrals?.map((d) => d.teamCount) ?? [];
|
||||||
|
const leaderboardScore = LeaderboardScore.calculate(props.totalTeamCount, directTeamCounts);
|
||||||
|
|
||||||
|
const directReferralStats = new Map<bigint, number>();
|
||||||
|
props.directReferrals?.forEach((d) => {
|
||||||
|
directReferralStats.set(d.referralId, d.teamCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new TeamStatistics(
|
||||||
|
props.id,
|
||||||
|
UserId.create(props.userId),
|
||||||
|
props.directReferralCount,
|
||||||
|
props.totalTeamCount,
|
||||||
|
props.personalPlantingCount,
|
||||||
|
props.teamPlantingCount,
|
||||||
|
leaderboardScore,
|
||||||
|
ProvinceCityDistribution.fromJson(props.provinceCityDistribution),
|
||||||
|
props.lastCalculatedAt,
|
||||||
|
props.createdAt,
|
||||||
|
props.updatedAt,
|
||||||
|
directReferralStats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增直推成员
|
||||||
|
*/
|
||||||
|
addDirectReferral(referralUserId: bigint): void {
|
||||||
|
this._directReferralCount++;
|
||||||
|
this._directReferralStats.set(referralUserId, 0);
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加团队认种量
|
||||||
|
* @param count 认种数量
|
||||||
|
* @param provinceCode 省份代码
|
||||||
|
* @param cityCode 城市代码
|
||||||
|
* @param fromDirectReferralId 来自哪个直推 (如果是直推的下级)
|
||||||
|
*/
|
||||||
|
addTeamPlanting(
|
||||||
|
count: number,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
fromDirectReferralId?: bigint,
|
||||||
|
): void {
|
||||||
|
this._teamPlantingCount += count;
|
||||||
|
this._totalTeamCount += count;
|
||||||
|
this._provinceCityDistribution = this._provinceCityDistribution.add(
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新直推团队统计
|
||||||
|
if (fromDirectReferralId) {
|
||||||
|
const currentCount = this._directReferralStats.get(fromDirectReferralId) ?? 0;
|
||||||
|
this._directReferralStats.set(fromDirectReferralId, currentCount + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新计算龙虎榜分值
|
||||||
|
this.recalculateLeaderboardScore();
|
||||||
|
this._lastCalculatedAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this._domainEvents.push(
|
||||||
|
new TeamStatisticsUpdatedEvent(
|
||||||
|
this._userId.value,
|
||||||
|
this._totalTeamCount,
|
||||||
|
this._directReferralCount,
|
||||||
|
this._leaderboardScore.score,
|
||||||
|
'planting_added',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加个人认种量
|
||||||
|
*/
|
||||||
|
addPersonalPlanting(count: number, provinceCode: string, cityCode: string): void {
|
||||||
|
this._personalPlantingCount += count;
|
||||||
|
this._totalTeamCount += count;
|
||||||
|
this._provinceCityDistribution = this._provinceCityDistribution.add(
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
this.recalculateLeaderboardScore();
|
||||||
|
this._lastCalculatedAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
|
||||||
|
this._domainEvents.push(
|
||||||
|
new TeamStatisticsUpdatedEvent(
|
||||||
|
this._userId.value,
|
||||||
|
this._totalTeamCount,
|
||||||
|
this._directReferralCount,
|
||||||
|
this._leaderboardScore.score,
|
||||||
|
'planting_added',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新计算龙虎榜分值
|
||||||
|
*/
|
||||||
|
private recalculateLeaderboardScore(): void {
|
||||||
|
const directTeamCounts = Array.from(this._directReferralStats.values());
|
||||||
|
this._leaderboardScore = this._leaderboardScore.recalculate(
|
||||||
|
this._totalTeamCount,
|
||||||
|
directTeamCounts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直推团队统计
|
||||||
|
*/
|
||||||
|
getDirectReferralStats(): Map<bigint, number> {
|
||||||
|
return new Map(this._directReferralStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除领域事件
|
||||||
|
*/
|
||||||
|
clearDomainEvents(): void {
|
||||||
|
this._domainEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为持久化格式
|
||||||
|
*/
|
||||||
|
toPersistence(): TeamStatisticsProps {
|
||||||
|
const directReferrals: DirectReferralStats[] = [];
|
||||||
|
this._directReferralStats.forEach((teamCount, referralId) => {
|
||||||
|
directReferrals.push({ referralId, teamCount });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this._id,
|
||||||
|
userId: this._userId.value,
|
||||||
|
directReferralCount: this._directReferralCount,
|
||||||
|
totalTeamCount: this._totalTeamCount,
|
||||||
|
personalPlantingCount: this._personalPlantingCount,
|
||||||
|
teamPlantingCount: this._teamPlantingCount,
|
||||||
|
leaderboardScore: this._leaderboardScore.score,
|
||||||
|
maxDirectTeamCount: this._leaderboardScore.maxDirectTeamCount,
|
||||||
|
provinceCityDistribution: this._provinceCityDistribution.toJson(),
|
||||||
|
lastCalculatedAt: this._lastCalculatedAt,
|
||||||
|
createdAt: this._createdAt,
|
||||||
|
updatedAt: this._updatedAt,
|
||||||
|
directReferrals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* 领域事件基类
|
||||||
|
*/
|
||||||
|
export abstract class DomainEvent {
|
||||||
|
public readonly occurredAt: Date;
|
||||||
|
public readonly eventId: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
this.eventId = this.generateEventId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateEventId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract get eventName(): string;
|
||||||
|
abstract toPayload(): Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './domain-event.base';
|
||||||
|
export * from './referral-relationship-created.event';
|
||||||
|
export * from './team-statistics-updated.event';
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐关系创建事件
|
||||||
|
*/
|
||||||
|
export class ReferralRelationshipCreatedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: bigint,
|
||||||
|
public readonly referrerId: bigint | null,
|
||||||
|
public readonly referralCode: string,
|
||||||
|
public readonly referralChain: bigint[],
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventName(): string {
|
||||||
|
return 'referral.relationship.created';
|
||||||
|
}
|
||||||
|
|
||||||
|
toPayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
eventId: this.eventId,
|
||||||
|
eventName: this.eventName,
|
||||||
|
occurredAt: this.occurredAt.toISOString(),
|
||||||
|
data: {
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
referrerId: this.referrerId?.toString() ?? null,
|
||||||
|
referralCode: this.referralCode,
|
||||||
|
referralChain: this.referralChain.map((id) => id.toString()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { DomainEvent } from './domain-event.base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队统计更新事件
|
||||||
|
*/
|
||||||
|
export class TeamStatisticsUpdatedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: bigint,
|
||||||
|
public readonly totalTeamCount: number,
|
||||||
|
public readonly directReferralCount: number,
|
||||||
|
public readonly leaderboardScore: number,
|
||||||
|
public readonly updateReason: 'planting_added' | 'planting_removed' | 'member_joined' | 'recalculation',
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventName(): string {
|
||||||
|
return 'referral.team_statistics.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
toPayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
eventId: this.eventId,
|
||||||
|
eventName: this.eventName,
|
||||||
|
occurredAt: this.occurredAt.toISOString(),
|
||||||
|
data: {
|
||||||
|
userId: this.userId.toString(),
|
||||||
|
totalTeamCount: this.totalTeamCount,
|
||||||
|
directReferralCount: this.directReferralCount,
|
||||||
|
leaderboardScore: this.leaderboardScore,
|
||||||
|
updateReason: this.updateReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './value-objects';
|
||||||
|
export * from './events';
|
||||||
|
export * from './aggregates';
|
||||||
|
export * from './repositories';
|
||||||
|
export * from './services';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './referral-relationship.repository.interface';
|
||||||
|
export * from './team-statistics.repository.interface';
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { ReferralRelationship } from '../aggregates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐关系仓储接口
|
||||||
|
*/
|
||||||
|
export interface IReferralRelationshipRepository {
|
||||||
|
/**
|
||||||
|
* 保存推荐关系
|
||||||
|
*/
|
||||||
|
save(relationship: ReferralRelationship): Promise<ReferralRelationship>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户ID查找
|
||||||
|
*/
|
||||||
|
findByUserId(userId: bigint): Promise<ReferralRelationship | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据推荐码查找
|
||||||
|
*/
|
||||||
|
findByReferralCode(code: string): Promise<ReferralRelationship | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找用户的直推列表
|
||||||
|
*/
|
||||||
|
findDirectReferrals(userId: bigint): Promise<ReferralRelationship[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查推荐码是否存在
|
||||||
|
*/
|
||||||
|
existsByReferralCode(code: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已有推荐关系
|
||||||
|
*/
|
||||||
|
existsByUserId(userId: bigint): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取推荐链 (用于创建新用户时)
|
||||||
|
*/
|
||||||
|
getReferralChain(userId: bigint): Promise<bigint[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REFERRAL_RELATIONSHIP_REPOSITORY = Symbol('IReferralRelationshipRepository');
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { TeamStatistics } from '../aggregates';
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
userId: bigint;
|
||||||
|
score: number;
|
||||||
|
rank: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
directReferralCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardQueryOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队统计仓储接口
|
||||||
|
*/
|
||||||
|
export interface ITeamStatisticsRepository {
|
||||||
|
/**
|
||||||
|
* 保存团队统计
|
||||||
|
*/
|
||||||
|
save(statistics: TeamStatistics): Promise<TeamStatistics>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户ID查找
|
||||||
|
*/
|
||||||
|
findByUserId(userId: bigint): Promise<TeamStatistics | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取用户的团队统计
|
||||||
|
*/
|
||||||
|
findByUserIds(userIds: bigint[]): Promise<TeamStatistics[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取龙虎榜排名
|
||||||
|
*/
|
||||||
|
getLeaderboard(options?: LeaderboardQueryOptions): Promise<LeaderboardEntry[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户在龙虎榜的排名
|
||||||
|
*/
|
||||||
|
getUserRank(userId: bigint): Promise<number | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新团队统计 (用于认种事件处理)
|
||||||
|
*/
|
||||||
|
batchUpdateTeamCounts(
|
||||||
|
updates: Array<{
|
||||||
|
userId: bigint;
|
||||||
|
countDelta: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
fromDirectReferralId?: bigint;
|
||||||
|
}>,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建初始团队统计记录
|
||||||
|
*/
|
||||||
|
create(userId: bigint): Promise<TeamStatistics>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository');
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './referral-chain.service';
|
||||||
|
export * from './leaderboard-calculation.service';
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { LeaderboardScore } from '../value-objects';
|
||||||
|
|
||||||
|
export interface DirectTeamStats {
|
||||||
|
referralId: bigint;
|
||||||
|
teamCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 龙虎榜计算领域服务
|
||||||
|
*
|
||||||
|
* 计算公式: 团队总认种量 - 最大单个直推团队认种量
|
||||||
|
*
|
||||||
|
* 设计目的:
|
||||||
|
* - 鼓励均衡发展团队
|
||||||
|
* - 防止\"烧伤\"现象(单腿发展)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LeaderboardCalculationService {
|
||||||
|
/**
|
||||||
|
* 计算龙虎榜分值
|
||||||
|
*/
|
||||||
|
calculateScore(totalTeamCount: number, directTeamStats: DirectTeamStats[]): LeaderboardScore {
|
||||||
|
const directTeamCounts = directTeamStats.map((s) => s.teamCount);
|
||||||
|
return LeaderboardScore.calculate(totalTeamCount, directTeamCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新分值 (当有新认种时)
|
||||||
|
* @param currentScore 当前分值
|
||||||
|
* @param newPlantingCount 新增认种数量
|
||||||
|
* @param fromDirectReferralId 来自哪个直推 (如果有)
|
||||||
|
* @param directTeamStats 所有直推团队统计
|
||||||
|
*/
|
||||||
|
updateScoreOnPlanting(
|
||||||
|
currentScore: LeaderboardScore,
|
||||||
|
newPlantingCount: number,
|
||||||
|
fromDirectReferralId: bigint | undefined,
|
||||||
|
directTeamStats: DirectTeamStats[],
|
||||||
|
): LeaderboardScore {
|
||||||
|
// 更新直推团队统计
|
||||||
|
const updatedStats = directTeamStats.map((s) => {
|
||||||
|
if (fromDirectReferralId && s.referralId === fromDirectReferralId) {
|
||||||
|
return { ...s, teamCount: s.teamCount + newPlantingCount };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTotalTeamCount = currentScore.totalTeamCount + newPlantingCount;
|
||||||
|
return this.calculateScore(newTotalTeamCount, updatedStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较两个用户的排名
|
||||||
|
* @returns 负数表示a排在b前面, 正数表示b排在a前面
|
||||||
|
*/
|
||||||
|
compareRank(scoreA: LeaderboardScore, scoreB: LeaderboardScore): number {
|
||||||
|
return scoreA.compareTo(scoreB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查分值是否有效 (用于数据验证)
|
||||||
|
*/
|
||||||
|
validateScore(score: LeaderboardScore, totalTeamCount: number, maxDirectTeamCount: number): boolean {
|
||||||
|
const expectedScore = Math.max(0, totalTeamCount - maxDirectTeamCount);
|
||||||
|
return score.score === expectedScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ReferralChain } from '../value-objects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐链领域服务
|
||||||
|
* 处理推荐链的构建和验证逻辑
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReferralChainService {
|
||||||
|
private static readonly MAX_DEPTH = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建新用户的推荐链
|
||||||
|
* @param referrerId 推荐人ID
|
||||||
|
* @param parentChain 推荐人的推荐链
|
||||||
|
*/
|
||||||
|
buildChain(referrerId: bigint | null, parentChain: bigint[] = []): ReferralChain {
|
||||||
|
return ReferralChain.create(referrerId, parentChain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证推荐链是否有效
|
||||||
|
* - 不能形成循环
|
||||||
|
* - 深度不能超过限制
|
||||||
|
*/
|
||||||
|
validateChain(chain: bigint[], newUserId: bigint): boolean {
|
||||||
|
// 检查是否有循环
|
||||||
|
if (chain.includes(newUserId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查深度
|
||||||
|
if (chain.length >= ReferralChainService.MAX_DEPTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取需要更新团队统计的所有上级
|
||||||
|
* @param chain 推荐链
|
||||||
|
* @param maxLevels 最大层级 (默认全部)
|
||||||
|
*/
|
||||||
|
getAncestorsForUpdate(chain: bigint[], maxLevels?: number): bigint[] {
|
||||||
|
if (!maxLevels) {
|
||||||
|
return [...chain];
|
||||||
|
}
|
||||||
|
return chain.slice(0, maxLevels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算用户在推荐链中的层级
|
||||||
|
* @param chain 推荐链
|
||||||
|
* @param ancestorId 上级ID
|
||||||
|
* @returns 层级 (0 = 直推, 1 = 二级, ...), 如果不在链中返回 -1
|
||||||
|
*/
|
||||||
|
getLevelInChain(chain: bigint[], ancestorId: bigint): number {
|
||||||
|
const index = chain.findIndex((id) => id === ancestorId);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
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';
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* 龙虎榜分值值对象
|
||||||
|
*
|
||||||
|
* 计算公式: 团队总认种量 - 最大单个直推团队认种量
|
||||||
|
*
|
||||||
|
* 这个公式的设计目的:
|
||||||
|
* - 鼓励均衡发展团队,而不是只依赖单个大团队
|
||||||
|
* - 防止"烧伤"现象(单腿发展)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 推荐链值对象
|
||||||
|
* 存储从直接推荐人到根节点的用户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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
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).padEnd(4, '0');
|
||||||
|
return new ReferralCode(`${prefix}${userIdHash}${random}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: ReferralCode): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './redis.service';
|
||||||
|
export * from './leaderboard-cache.service';
|
||||||
85
backend/services/referral-service/src/infrastructure/cache/leaderboard-cache.service.ts
vendored
Normal file
85
backend/services/referral-service/src/infrastructure/cache/leaderboard-cache.service.ts
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
import { LeaderboardEntry } from '../../domain';
|
||||||
|
|
||||||
|
const LEADERBOARD_KEY = 'leaderboard:scores';
|
||||||
|
const LEADERBOARD_CACHE_KEY = 'leaderboard:cache';
|
||||||
|
const CACHE_TTL_SECONDS = 300; // 5分钟
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LeaderboardCacheService {
|
||||||
|
private readonly logger = new Logger(LeaderboardCacheService.name);
|
||||||
|
|
||||||
|
constructor(private readonly redisService: RedisService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户的龙虎榜分值
|
||||||
|
*/
|
||||||
|
async updateScore(userId: bigint, score: number): Promise<void> {
|
||||||
|
await this.redisService.zadd(LEADERBOARD_KEY, score, userId.toString());
|
||||||
|
this.logger.debug(`Updated leaderboard score for user ${userId}: ${score}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户排名 (0-based)
|
||||||
|
*/
|
||||||
|
async getUserRank(userId: bigint): Promise<number | null> {
|
||||||
|
const rank = await this.redisService.zrevrank(LEADERBOARD_KEY, userId.toString());
|
||||||
|
return rank !== null ? rank + 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取排行榜前N名
|
||||||
|
*/
|
||||||
|
async getTopN(n: number): Promise<LeaderboardEntry[]> {
|
||||||
|
// 尝试从缓存获取
|
||||||
|
const cached = await this.redisService.get<LeaderboardEntry[]>(`${LEADERBOARD_CACHE_KEY}:top${n}`);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从有序集合获取
|
||||||
|
const items = await this.redisService.zrevrangeWithScores(LEADERBOARD_KEY, 0, n - 1);
|
||||||
|
const entries: LeaderboardEntry[] = items.map((item, index) => ({
|
||||||
|
userId: BigInt(item.member),
|
||||||
|
score: item.score,
|
||||||
|
rank: index + 1,
|
||||||
|
totalTeamCount: 0, // 需要从数据库补充
|
||||||
|
directReferralCount: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
await this.redisService.set(`${LEADERBOARD_CACHE_KEY}:top${n}`, entries, CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新分值
|
||||||
|
*/
|
||||||
|
async incrementScore(userId: bigint, delta: number): Promise<number> {
|
||||||
|
return this.redisService.zincrby(LEADERBOARD_KEY, delta, userId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新分值
|
||||||
|
*/
|
||||||
|
async batchUpdateScores(updates: Array<{ userId: bigint; score: number }>): Promise<void> {
|
||||||
|
for (const update of updates) {
|
||||||
|
await this.updateScore(update.userId, update.score);
|
||||||
|
}
|
||||||
|
// 清除缓存
|
||||||
|
await this.invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除排行榜缓存
|
||||||
|
*/
|
||||||
|
async invalidateCache(): Promise<void> {
|
||||||
|
// 清除所有排行榜缓存
|
||||||
|
for (const n of [10, 50, 100]) {
|
||||||
|
await this.redisService.del(`${LEADERBOARD_CACHE_KEY}:top${n}`);
|
||||||
|
}
|
||||||
|
this.logger.debug('Leaderboard cache invalidated');
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backend/services/referral-service/src/infrastructure/cache/redis.service.ts
vendored
Normal file
120
backend/services/referral-service/src/infrastructure/cache/redis.service.ts
vendored
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisService.name);
|
||||||
|
private client: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.client = new Redis({
|
||||||
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||||
|
db: this.configService.get<number>('REDIS_DB', 0),
|
||||||
|
keyPrefix: 'referral:',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Connecting to Redis...');
|
||||||
|
await this.client.ping();
|
||||||
|
this.logger.log('Redis connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
this.logger.log('Disconnecting from Redis...');
|
||||||
|
await this.client.quit();
|
||||||
|
this.logger.log('Redis disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const value = await this.client.get(key);
|
||||||
|
if (!value) return null;
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (ttlSeconds) {
|
||||||
|
await this.client.setex(key, ttlSeconds, serialized);
|
||||||
|
} else {
|
||||||
|
await this.client.set(key, serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.client.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
return (await this.client.exists(key)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排行榜相关操作
|
||||||
|
async zadd(key: string, score: number, member: string): Promise<void> {
|
||||||
|
await this.client.zadd(key, score, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrevrank(key: string, member: string): Promise<number | null> {
|
||||||
|
return this.client.zrevrank(key, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||||
|
return this.client.zrevrange(key, start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zrevrangeWithScores(
|
||||||
|
key: string,
|
||||||
|
start: number,
|
||||||
|
stop: number,
|
||||||
|
): Promise<Array<{ member: string; score: number }>> {
|
||||||
|
const result = await this.client.zrevrange(key, start, stop, 'WITHSCORES');
|
||||||
|
const items: Array<{ member: string; score: number }> = [];
|
||||||
|
for (let i = 0; i < result.length; i += 2) {
|
||||||
|
items.push({
|
||||||
|
member: result[i],
|
||||||
|
score: parseFloat(result[i + 1]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async zincrby(key: string, increment: number, member: string): Promise<number> {
|
||||||
|
const result = await this.client.zincrby(key, increment, member);
|
||||||
|
return parseFloat(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希操作
|
||||||
|
async hget<T>(key: string, field: string): Promise<T | null> {
|
||||||
|
const value = await this.client.hget(key, field);
|
||||||
|
if (!value) return null;
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hset(key: string, field: string, value: unknown): Promise<void> {
|
||||||
|
await this.client.hset(key, field, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async hmset(key: string, data: Record<string, unknown>): Promise<void> {
|
||||||
|
const serialized: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
serialized[k] = JSON.stringify(v);
|
||||||
|
}
|
||||||
|
await this.client.hmset(key, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hgetall<T>(key: string): Promise<Record<string, T>> {
|
||||||
|
const data = await this.client.hgetall(key);
|
||||||
|
const result: Record<string, T> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
result[k] = JSON.parse(v) as T;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hdel(key: string, ...fields: string[]): Promise<void> {
|
||||||
|
await this.client.hdel(key, ...fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './prisma.service';
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
log: [
|
||||||
|
{ emit: 'event', level: 'query' },
|
||||||
|
{ emit: 'stdout', level: 'info' },
|
||||||
|
{ emit: 'stdout', level: 'warn' },
|
||||||
|
{ emit: 'stdout', level: 'error' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Connecting to database...');
|
||||||
|
await this.$connect();
|
||||||
|
this.logger.log('Database connected successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
this.logger.log('Disconnecting from database...');
|
||||||
|
await this.$disconnect();
|
||||||
|
this.logger.log('Database disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './database';
|
||||||
|
export * from './repositories';
|
||||||
|
export * from './messaging';
|
||||||
|
export * from './cache';
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { KafkaService } from './kafka.service';
|
||||||
|
import { DomainEvent } from '../../domain';
|
||||||
|
|
||||||
|
export const KAFKA_TOPICS = {
|
||||||
|
REFERRAL_EVENTS: 'referral.events',
|
||||||
|
TEAM_STATISTICS_EVENTS: 'referral.team-statistics.events',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventPublisherService {
|
||||||
|
private readonly logger = new Logger(EventPublisherService.name);
|
||||||
|
|
||||||
|
constructor(private readonly kafkaService: KafkaService) {}
|
||||||
|
|
||||||
|
async publishDomainEvents(events: DomainEvent[]): Promise<void> {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
|
||||||
|
const messages = events.map((event) => ({
|
||||||
|
topic: this.getTopicForEvent(event),
|
||||||
|
key: event.eventId,
|
||||||
|
value: event.toPayload(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.kafkaService.publishBatch(messages);
|
||||||
|
this.logger.log(`Published ${events.length} domain events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishEvent(event: DomainEvent): Promise<void> {
|
||||||
|
await this.kafkaService.publish({
|
||||||
|
topic: this.getTopicForEvent(event),
|
||||||
|
key: event.eventId,
|
||||||
|
value: event.toPayload(),
|
||||||
|
});
|
||||||
|
this.logger.debug(`Published event: ${event.eventName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTopicForEvent(event: DomainEvent): string {
|
||||||
|
if (event.eventName.includes('team_statistics')) {
|
||||||
|
return KAFKA_TOPICS.TEAM_STATISTICS_EVENTS;
|
||||||
|
}
|
||||||
|
return KAFKA_TOPICS.REFERRAL_EVENTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './kafka.service';
|
||||||
|
export * from './event-publisher.service';
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Kafka, Producer, Consumer, logLevel } from 'kafkajs';
|
||||||
|
|
||||||
|
export interface KafkaMessage {
|
||||||
|
topic: string;
|
||||||
|
key?: string;
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KafkaService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(KafkaService.name);
|
||||||
|
private kafka: Kafka;
|
||||||
|
private producer: Producer;
|
||||||
|
private consumers: Map<string, Consumer> = new Map();
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.kafka = new Kafka({
|
||||||
|
clientId: 'referral-service',
|
||||||
|
brokers: this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
|
||||||
|
logLevel: logLevel.WARN,
|
||||||
|
});
|
||||||
|
this.producer = this.kafka.producer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Connecting to Kafka...');
|
||||||
|
await this.producer.connect();
|
||||||
|
this.logger.log('Kafka producer connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
this.logger.log('Disconnecting from Kafka...');
|
||||||
|
await this.producer.disconnect();
|
||||||
|
for (const [groupId, consumer] of this.consumers) {
|
||||||
|
await consumer.disconnect();
|
||||||
|
this.logger.log(`Consumer ${groupId} disconnected`);
|
||||||
|
}
|
||||||
|
this.logger.log('Kafka disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(message: KafkaMessage): Promise<void> {
|
||||||
|
await this.producer.send({
|
||||||
|
topic: message.topic,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: message.key,
|
||||||
|
value: JSON.stringify(message.value),
|
||||||
|
headers: message.headers,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
this.logger.debug(`Published message to ${message.topic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishBatch(messages: KafkaMessage[]): Promise<void> {
|
||||||
|
const topicMessages = new Map<string, Array<{ key?: string; value: string; headers?: Record<string, string> }>>();
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!topicMessages.has(msg.topic)) {
|
||||||
|
topicMessages.set(msg.topic, []);
|
||||||
|
}
|
||||||
|
topicMessages.get(msg.topic)!.push({
|
||||||
|
key: msg.key,
|
||||||
|
value: JSON.stringify(msg.value),
|
||||||
|
headers: msg.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.producer.sendBatch({
|
||||||
|
topicMessages: Array.from(topicMessages.entries()).map(([topic, messages]) => ({
|
||||||
|
topic,
|
||||||
|
messages,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Published ${messages.length} messages in batch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(
|
||||||
|
groupId: string,
|
||||||
|
topics: string[],
|
||||||
|
handler: (topic: string, message: Record<string, unknown>) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const consumer = this.kafka.consumer({ groupId });
|
||||||
|
await consumer.connect();
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
await consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumer.run({
|
||||||
|
eachMessage: async ({ topic, message }) => {
|
||||||
|
try {
|
||||||
|
const value = message.value ? JSON.parse(message.value.toString()) : null;
|
||||||
|
if (value) {
|
||||||
|
await handler(topic, value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing message from ${topic}:`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumers.set(groupId, consumer);
|
||||||
|
this.logger.log(`Consumer ${groupId} subscribed to topics: ${topics.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './referral-relationship.repository';
|
||||||
|
export * from './team-statistics.repository';
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import {
|
||||||
|
IReferralRelationshipRepository,
|
||||||
|
ReferralRelationship,
|
||||||
|
ReferralRelationshipProps,
|
||||||
|
} from '../../domain';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReferralRelationshipRepository implements IReferralRelationshipRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(relationship: ReferralRelationship): Promise<ReferralRelationship> {
|
||||||
|
const data = relationship.toPersistence();
|
||||||
|
|
||||||
|
const saved = await this.prisma.referralRelationship.upsert({
|
||||||
|
where: { userId: data.userId },
|
||||||
|
update: {
|
||||||
|
referrerId: data.referrerId,
|
||||||
|
myReferralCode: data.referralCode,
|
||||||
|
ancestorPath: data.referralChain,
|
||||||
|
depth: data.referralChain.length,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: data.userId,
|
||||||
|
referrerId: data.referrerId,
|
||||||
|
myReferralCode: data.referralCode,
|
||||||
|
usedReferralCode: data.referrerId ? data.referralCode : null,
|
||||||
|
ancestorPath: data.referralChain,
|
||||||
|
depth: data.referralChain.length,
|
||||||
|
rootUserId: data.referralChain.length > 0 ? data.referralChain[data.referralChain.length - 1] : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ReferralRelationship.reconstitute(this.mapToProps(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint): Promise<ReferralRelationship | null> {
|
||||||
|
const record = await this.prisma.referralRelationship.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
return ReferralRelationship.reconstitute(this.mapToProps(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByReferralCode(code: string): Promise<ReferralRelationship | null> {
|
||||||
|
const record = await this.prisma.referralRelationship.findUnique({
|
||||||
|
where: { myReferralCode: code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
return ReferralRelationship.reconstitute(this.mapToProps(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDirectReferrals(userId: bigint): Promise<ReferralRelationship[]> {
|
||||||
|
const records = await this.prisma.referralRelationship.findMany({
|
||||||
|
where: { referrerId: userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => ReferralRelationship.reconstitute(this.mapToProps(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByReferralCode(code: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.referralRelationship.count({
|
||||||
|
where: { myReferralCode: code },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByUserId(userId: bigint): Promise<boolean> {
|
||||||
|
const count = await this.prisma.referralRelationship.count({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReferralChain(userId: bigint): Promise<bigint[]> {
|
||||||
|
const record = await this.prisma.referralRelationship.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { ancestorPath: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return [];
|
||||||
|
return record.ancestorPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToProps(record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
referrerId: bigint | null;
|
||||||
|
myReferralCode: string;
|
||||||
|
ancestorPath: bigint[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ReferralRelationshipProps {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
referrerId: record.referrerId,
|
||||||
|
referralCode: record.myReferralCode,
|
||||||
|
referralChain: record.ancestorPath,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import {
|
||||||
|
ITeamStatisticsRepository,
|
||||||
|
TeamStatistics,
|
||||||
|
TeamStatisticsProps,
|
||||||
|
LeaderboardEntry,
|
||||||
|
LeaderboardQueryOptions,
|
||||||
|
DirectReferralStats,
|
||||||
|
} from '../../domain';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(statistics: TeamStatistics): Promise<TeamStatistics> {
|
||||||
|
const data = statistics.toPersistence();
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 保存主表
|
||||||
|
await tx.teamStatistics.upsert({
|
||||||
|
where: { userId: data.userId },
|
||||||
|
update: {
|
||||||
|
directReferralCount: data.directReferralCount,
|
||||||
|
totalTeamCount: data.totalTeamCount,
|
||||||
|
selfPlantingCount: data.personalPlantingCount,
|
||||||
|
totalTeamPlantingCount: data.teamPlantingCount,
|
||||||
|
effectivePlantingCountForRanking: data.leaderboardScore,
|
||||||
|
maxSingleTeamPlantingCount: data.maxDirectTeamCount,
|
||||||
|
provinceCityDistribution: data.provinceCityDistribution ?? {},
|
||||||
|
lastCalcAt: data.lastCalculatedAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: data.userId,
|
||||||
|
directReferralCount: data.directReferralCount,
|
||||||
|
totalTeamCount: data.totalTeamCount,
|
||||||
|
selfPlantingCount: data.personalPlantingCount,
|
||||||
|
totalTeamPlantingCount: data.teamPlantingCount,
|
||||||
|
effectivePlantingCountForRanking: data.leaderboardScore,
|
||||||
|
maxSingleTeamPlantingCount: data.maxDirectTeamCount,
|
||||||
|
provinceCityDistribution: data.provinceCityDistribution ?? {},
|
||||||
|
lastCalcAt: data.lastCalculatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新直推统计明细
|
||||||
|
if (data.directReferrals && data.directReferrals.length > 0) {
|
||||||
|
for (const dr of data.directReferrals) {
|
||||||
|
await tx.directReferral.upsert({
|
||||||
|
where: {
|
||||||
|
uk_referrer_referral: {
|
||||||
|
referrerId: data.userId,
|
||||||
|
referralId: dr.referralId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
teamPlantingCount: dr.teamCount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
referrerId: data.userId,
|
||||||
|
referralId: dr.referralId,
|
||||||
|
referralSequence: dr.referralId,
|
||||||
|
teamPlantingCount: dr.teamCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新查询完整数据
|
||||||
|
return this.findByUserId(data.userId) as Promise<TeamStatistics>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint): Promise<TeamStatistics | null> {
|
||||||
|
const record = await this.prisma.teamStatistics.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
// 查询直推统计
|
||||||
|
const directReferrals = await this.prisma.directReferral.findMany({
|
||||||
|
where: { referrerId: userId },
|
||||||
|
select: { referralId: true, teamPlantingCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return TeamStatistics.reconstitute(this.mapToProps(record, directReferrals));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserIds(userIds: bigint[]): Promise<TeamStatistics[]> {
|
||||||
|
const records = await this.prisma.teamStatistics.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: TeamStatistics[] = [];
|
||||||
|
for (const record of records) {
|
||||||
|
const directReferrals = await this.prisma.directReferral.findMany({
|
||||||
|
where: { referrerId: record.userId },
|
||||||
|
select: { referralId: true, teamPlantingCount: true },
|
||||||
|
});
|
||||||
|
result.push(TeamStatistics.reconstitute(this.mapToProps(record, directReferrals)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeaderboard(options: LeaderboardQueryOptions = {}): Promise<LeaderboardEntry[]> {
|
||||||
|
const { limit = 100, offset = 0 } = options;
|
||||||
|
|
||||||
|
const records = await this.prisma.teamStatistics.findMany({
|
||||||
|
where: {
|
||||||
|
effectivePlantingCountForRanking: { gt: 0 },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ effectivePlantingCountForRanking: 'desc' },
|
||||||
|
{ totalTeamPlantingCount: 'desc' },
|
||||||
|
],
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
effectivePlantingCountForRanking: true,
|
||||||
|
totalTeamPlantingCount: true,
|
||||||
|
directReferralCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r, index) => ({
|
||||||
|
userId: r.userId,
|
||||||
|
score: r.effectivePlantingCountForRanking,
|
||||||
|
rank: offset + index + 1,
|
||||||
|
totalTeamCount: r.totalTeamPlantingCount,
|
||||||
|
directReferralCount: r.directReferralCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRank(userId: bigint): Promise<number | null> {
|
||||||
|
const userStats = await this.prisma.teamStatistics.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { effectivePlantingCountForRanking: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userStats) return null;
|
||||||
|
|
||||||
|
const higherRanks = await this.prisma.teamStatistics.count({
|
||||||
|
where: {
|
||||||
|
effectivePlantingCountForRanking: { gt: userStats.effectivePlantingCountForRanking },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return higherRanks + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchUpdateTeamCounts(
|
||||||
|
updates: Array<{
|
||||||
|
userId: bigint;
|
||||||
|
countDelta: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
fromDirectReferralId?: bigint;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const update of updates) {
|
||||||
|
// 获取当前统计
|
||||||
|
const current = await tx.teamStatistics.findUnique({
|
||||||
|
where: { userId: update.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) continue;
|
||||||
|
|
||||||
|
// 更新省市分布
|
||||||
|
const distribution = (current.provinceCityDistribution as Record<
|
||||||
|
string,
|
||||||
|
Record<string, number>
|
||||||
|
>) ?? {};
|
||||||
|
if (!distribution[update.provinceCode]) {
|
||||||
|
distribution[update.provinceCode] = {};
|
||||||
|
}
|
||||||
|
distribution[update.provinceCode][update.cityCode] =
|
||||||
|
(distribution[update.provinceCode][update.cityCode] ?? 0) + update.countDelta;
|
||||||
|
|
||||||
|
// 更新直推团队统计
|
||||||
|
let newMaxDirectTeamCount = current.maxSingleTeamPlantingCount;
|
||||||
|
if (update.fromDirectReferralId) {
|
||||||
|
const directRef = await tx.directReferral.upsert({
|
||||||
|
where: {
|
||||||
|
uk_referrer_referral: {
|
||||||
|
referrerId: update.userId,
|
||||||
|
referralId: update.fromDirectReferralId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
teamPlantingCount: { increment: update.countDelta },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
referrerId: update.userId,
|
||||||
|
referralId: update.fromDirectReferralId,
|
||||||
|
referralSequence: update.fromDirectReferralId,
|
||||||
|
teamPlantingCount: update.countDelta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directRef.teamPlantingCount > newMaxDirectTeamCount) {
|
||||||
|
newMaxDirectTeamCount = directRef.teamPlantingCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新计算龙虎榜分值
|
||||||
|
const newTotalTeamPlantingCount = current.totalTeamPlantingCount + update.countDelta;
|
||||||
|
const newEffectiveScore = Math.max(0, newTotalTeamPlantingCount - newMaxDirectTeamCount);
|
||||||
|
|
||||||
|
// 更新主表
|
||||||
|
await tx.teamStatistics.update({
|
||||||
|
where: { userId: update.userId },
|
||||||
|
data: {
|
||||||
|
totalTeamPlantingCount: newTotalTeamPlantingCount,
|
||||||
|
effectivePlantingCountForRanking: newEffectiveScore,
|
||||||
|
maxSingleTeamPlantingCount: newMaxDirectTeamCount,
|
||||||
|
provinceCityDistribution: distribution,
|
||||||
|
lastCalcAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(userId: bigint): Promise<TeamStatistics> {
|
||||||
|
const created = await this.prisma.teamStatistics.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
directReferralCount: 0,
|
||||||
|
totalTeamCount: 0,
|
||||||
|
selfPlantingCount: 0,
|
||||||
|
totalTeamPlantingCount: 0,
|
||||||
|
effectivePlantingCountForRanking: 0,
|
||||||
|
maxSingleTeamPlantingCount: 0,
|
||||||
|
provinceCityDistribution: {},
|
||||||
|
lastCalcAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return TeamStatistics.reconstitute(this.mapToProps(created, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToProps(
|
||||||
|
record: {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
directReferralCount: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
selfPlantingCount: number;
|
||||||
|
totalTeamPlantingCount: number;
|
||||||
|
effectivePlantingCountForRanking: number;
|
||||||
|
maxSingleTeamPlantingCount: number;
|
||||||
|
provinceCityDistribution: unknown;
|
||||||
|
lastCalcAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
directReferrals: Array<{
|
||||||
|
referralId: bigint;
|
||||||
|
teamPlantingCount: number;
|
||||||
|
}>,
|
||||||
|
): TeamStatisticsProps {
|
||||||
|
const directReferralStats: DirectReferralStats[] = directReferrals.map((dr) => ({
|
||||||
|
referralId: dr.referralId,
|
||||||
|
teamCount: dr.teamPlantingCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
directReferralCount: record.directReferralCount,
|
||||||
|
totalTeamCount: record.totalTeamCount,
|
||||||
|
personalPlantingCount: record.selfPlantingCount,
|
||||||
|
teamPlantingCount: record.totalTeamPlantingCount,
|
||||||
|
leaderboardScore: record.effectivePlantingCountForRanking,
|
||||||
|
maxDirectTeamCount: record.maxSingleTeamPlantingCount,
|
||||||
|
provinceCityDistribution: record.provinceCityDistribution as Record<
|
||||||
|
string,
|
||||||
|
Record<string, number>
|
||||||
|
> | null,
|
||||||
|
lastCalculatedAt: record.lastCalcAt ?? new Date(),
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
directReferrals: directReferralStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// 全局验证管道
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS配置
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN || '*',
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API前缀
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// Swagger文档
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Referral Service API')
|
||||||
|
.setDescription('推荐团队服务 API 文档')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('Referral', '推荐关系管理')
|
||||||
|
.addTag('Leaderboard', '龙虎榜')
|
||||||
|
.addTag('Team Statistics', '团队统计')
|
||||||
|
.addTag('Health', '健康检查')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
logger.log('Swagger documentation available at /api/docs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3002;
|
||||||
|
await app.listen(port);
|
||||||
|
logger.log(`Referral Service is running on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ApplicationModule } from './application.module';
|
||||||
|
import {
|
||||||
|
ReferralController,
|
||||||
|
LeaderboardController,
|
||||||
|
TeamStatisticsController,
|
||||||
|
HealthController,
|
||||||
|
} from '../api';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, ApplicationModule],
|
||||||
|
controllers: [
|
||||||
|
ReferralController,
|
||||||
|
LeaderboardController,
|
||||||
|
TeamStatisticsController,
|
||||||
|
HealthController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DomainModule } from './domain.module';
|
||||||
|
import { InfrastructureModule } from './infrastructure.module';
|
||||||
|
import {
|
||||||
|
ReferralService,
|
||||||
|
TeamStatisticsService,
|
||||||
|
UserRegisteredHandler,
|
||||||
|
PlantingCreatedHandler,
|
||||||
|
} from '../application';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DomainModule, InfrastructureModule],
|
||||||
|
providers: [
|
||||||
|
ReferralService,
|
||||||
|
TeamStatisticsService,
|
||||||
|
UserRegisteredHandler,
|
||||||
|
PlantingCreatedHandler,
|
||||||
|
],
|
||||||
|
exports: [ReferralService, TeamStatisticsService],
|
||||||
|
})
|
||||||
|
export class ApplicationModule {}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReferralChainService, LeaderboardCalculationService } from '../domain';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ReferralChainService, LeaderboardCalculationService],
|
||||||
|
exports: [ReferralChainService, LeaderboardCalculationService],
|
||||||
|
})
|
||||||
|
export class DomainModule {}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './domain.module';
|
||||||
|
export * from './infrastructure.module';
|
||||||
|
export * from './application.module';
|
||||||
|
export * from './api.module';
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
PrismaService,
|
||||||
|
ReferralRelationshipRepository,
|
||||||
|
TeamStatisticsRepository,
|
||||||
|
KafkaService,
|
||||||
|
EventPublisherService,
|
||||||
|
RedisService,
|
||||||
|
LeaderboardCacheService,
|
||||||
|
} from '../infrastructure';
|
||||||
|
import {
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
} from '../domain';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
KafkaService,
|
||||||
|
RedisService,
|
||||||
|
EventPublisherService,
|
||||||
|
LeaderboardCacheService,
|
||||||
|
{
|
||||||
|
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
useClass: ReferralRelationshipRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TEAM_STATISTICS_REPOSITORY,
|
||||||
|
useClass: TeamStatisticsRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
PrismaService,
|
||||||
|
KafkaService,
|
||||||
|
RedisService,
|
||||||
|
EventPublisherService,
|
||||||
|
LeaderboardCacheService,
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class InfrastructureModule {}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { ReferralRelationship } from '../../../src/domain/aggregates/referral-relationship/referral-relationship.aggregate';
|
||||||
|
import { ReferralRelationshipCreatedEvent } from '../../../src/domain/events';
|
||||||
|
|
||||||
|
describe('ReferralRelationship Aggregate', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create referral relationship without referrer', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
|
||||||
|
expect(relationship.userId).toBe(100n);
|
||||||
|
expect(relationship.referrerId).toBeNull();
|
||||||
|
expect(relationship.referralCode).toMatch(/^RWA/);
|
||||||
|
expect(relationship.referralChain).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create referral relationship with referrer', () => {
|
||||||
|
const parentChain = [200n, 300n];
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, parentChain);
|
||||||
|
|
||||||
|
expect(relationship.userId).toBe(100n);
|
||||||
|
expect(relationship.referrerId).toBe(50n);
|
||||||
|
expect(relationship.referralChain).toEqual([50n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit ReferralRelationshipCreatedEvent', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n);
|
||||||
|
|
||||||
|
expect(relationship.domainEvents.length).toBe(1);
|
||||||
|
expect(relationship.domainEvents[0]).toBeInstanceOf(ReferralRelationshipCreatedEvent);
|
||||||
|
|
||||||
|
const event = relationship.domainEvents[0] as ReferralRelationshipCreatedEvent;
|
||||||
|
expect(event.userId).toBe(100n);
|
||||||
|
expect(event.referrerId).toBe(50n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconstitute', () => {
|
||||||
|
it('should reconstitute from persistence data', () => {
|
||||||
|
const props = {
|
||||||
|
id: 1n,
|
||||||
|
userId: 100n,
|
||||||
|
referrerId: 50n,
|
||||||
|
referralCode: 'RWATEST123',
|
||||||
|
referralChain: [50n, 200n],
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const relationship = ReferralRelationship.reconstitute(props);
|
||||||
|
|
||||||
|
expect(relationship.id).toBe(1n);
|
||||||
|
expect(relationship.userId).toBe(100n);
|
||||||
|
expect(relationship.referrerId).toBe(50n);
|
||||||
|
expect(relationship.referralCode).toBe('RWATEST123');
|
||||||
|
expect(relationship.referralChain).toEqual([50n, 200n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDirectReferrer', () => {
|
||||||
|
it('should return direct referrer', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, [200n]);
|
||||||
|
expect(relationship.getDirectReferrer()).toBe(50n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no referrer', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
expect(relationship.getDirectReferrer()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReferrerAtLevel', () => {
|
||||||
|
it('should return referrer at specific level', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, [200n, 300n]);
|
||||||
|
|
||||||
|
expect(relationship.getReferrerAtLevel(0)).toBe(50n);
|
||||||
|
expect(relationship.getReferrerAtLevel(1)).toBe(200n);
|
||||||
|
expect(relationship.getReferrerAtLevel(2)).toBe(300n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllAncestorIds', () => {
|
||||||
|
it('should return all ancestor IDs', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, [200n, 300n]);
|
||||||
|
expect(relationship.getAllAncestorIds()).toEqual([50n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getChainDepth', () => {
|
||||||
|
it('should return chain depth', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, [200n, 300n]);
|
||||||
|
expect(relationship.getChainDepth()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearDomainEvents', () => {
|
||||||
|
it('should clear domain events', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n);
|
||||||
|
expect(relationship.domainEvents.length).toBe(1);
|
||||||
|
|
||||||
|
relationship.clearDomainEvents();
|
||||||
|
expect(relationship.domainEvents.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPersistence', () => {
|
||||||
|
it('should convert to persistence format', () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, [200n]);
|
||||||
|
const data = relationship.toPersistence();
|
||||||
|
|
||||||
|
expect(data.userId).toBe(100n);
|
||||||
|
expect(data.referrerId).toBe(50n);
|
||||||
|
expect(data.referralCode).toMatch(/^RWA/);
|
||||||
|
expect(data.referralChain).toEqual([50n, 200n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { TeamStatistics } from '../../../src/domain/aggregates/team-statistics/team-statistics.aggregate';
|
||||||
|
import { TeamStatisticsUpdatedEvent } from '../../../src/domain/events';
|
||||||
|
|
||||||
|
describe('TeamStatistics Aggregate', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create empty team statistics', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
|
||||||
|
expect(stats.userId).toBe(100n);
|
||||||
|
expect(stats.directReferralCount).toBe(0);
|
||||||
|
expect(stats.totalTeamCount).toBe(0);
|
||||||
|
expect(stats.personalPlantingCount).toBe(0);
|
||||||
|
expect(stats.teamPlantingCount).toBe(0);
|
||||||
|
expect(stats.leaderboardScore).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconstitute', () => {
|
||||||
|
it('should reconstitute from persistence data', () => {
|
||||||
|
const props = {
|
||||||
|
id: 1n,
|
||||||
|
userId: 100n,
|
||||||
|
directReferralCount: 5,
|
||||||
|
totalTeamCount: 100,
|
||||||
|
personalPlantingCount: 10,
|
||||||
|
teamPlantingCount: 90,
|
||||||
|
leaderboardScore: 60,
|
||||||
|
maxDirectTeamCount: 40,
|
||||||
|
provinceCityDistribution: { '110000': { '110100': 50 } },
|
||||||
|
lastCalculatedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
directReferrals: [
|
||||||
|
{ referralId: 200n, teamCount: 40 },
|
||||||
|
{ referralId: 300n, teamCount: 30 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = TeamStatistics.reconstitute(props);
|
||||||
|
|
||||||
|
expect(stats.id).toBe(1n);
|
||||||
|
expect(stats.userId).toBe(100n);
|
||||||
|
expect(stats.directReferralCount).toBe(5);
|
||||||
|
expect(stats.totalTeamCount).toBe(100);
|
||||||
|
expect(stats.leaderboardScore).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDirectReferral', () => {
|
||||||
|
it('should increment direct referral count', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
|
||||||
|
stats.addDirectReferral(200n);
|
||||||
|
expect(stats.directReferralCount).toBe(1);
|
||||||
|
|
||||||
|
stats.addDirectReferral(300n);
|
||||||
|
expect(stats.directReferralCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addPersonalPlanting', () => {
|
||||||
|
it('should add personal planting count', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
|
||||||
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
|
expect(stats.personalPlantingCount).toBe(10);
|
||||||
|
expect(stats.totalTeamCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit TeamStatisticsUpdatedEvent', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
|
expect(stats.domainEvents.length).toBe(1);
|
||||||
|
expect(stats.domainEvents[0]).toBeInstanceOf(TeamStatisticsUpdatedEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update province/city distribution', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
|
||||||
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
stats.addPersonalPlanting(5, '110000', '110100');
|
||||||
|
|
||||||
|
expect(stats.provinceCityDistribution.getCityTotal('110000', '110100')).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addTeamPlanting', () => {
|
||||||
|
it('should add team planting count', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
|
||||||
|
stats.addTeamPlanting(20, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
expect(stats.teamPlantingCount).toBe(20);
|
||||||
|
expect(stats.totalTeamCount).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track direct referral team count', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addDirectReferral(200n);
|
||||||
|
stats.addDirectReferral(300n);
|
||||||
|
|
||||||
|
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
||||||
|
stats.addTeamPlanting(20, '110000', '110100', 300n);
|
||||||
|
|
||||||
|
const directStats = stats.getDirectReferralStats();
|
||||||
|
expect(directStats.get(200n)).toBe(30);
|
||||||
|
expect(directStats.get(300n)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should recalculate leaderboard score', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addDirectReferral(200n);
|
||||||
|
stats.addDirectReferral(300n);
|
||||||
|
|
||||||
|
stats.addTeamPlanting(50, '110000', '110100', 200n);
|
||||||
|
stats.addTeamPlanting(30, '110000', '110100', 300n);
|
||||||
|
|
||||||
|
// Total = 80, max direct = 50, score = 30
|
||||||
|
expect(stats.leaderboardScore).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDirectReferralStats', () => {
|
||||||
|
it('should return copy of direct referral stats', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addDirectReferral(200n);
|
||||||
|
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
const directStats = stats.getDirectReferralStats();
|
||||||
|
directStats.set(999n, 100); // Modify copy
|
||||||
|
|
||||||
|
// Original should not be affected
|
||||||
|
expect(stats.getDirectReferralStats().has(999n)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearDomainEvents', () => {
|
||||||
|
it('should clear domain events', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
expect(stats.domainEvents.length).toBe(1);
|
||||||
|
|
||||||
|
stats.clearDomainEvents();
|
||||||
|
expect(stats.domainEvents.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPersistence', () => {
|
||||||
|
it('should convert to persistence format', () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addDirectReferral(200n);
|
||||||
|
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
const data = stats.toPersistence();
|
||||||
|
|
||||||
|
expect(data.userId).toBe(100n);
|
||||||
|
expect(data.directReferralCount).toBe(1);
|
||||||
|
expect(data.totalTeamCount).toBe(30);
|
||||||
|
expect(data.teamPlantingCount).toBe(30);
|
||||||
|
expect(data.directReferrals).toContainEqual({ referralId: 200n, teamCount: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { LeaderboardCalculationService } from '../../../src/domain/services/leaderboard-calculation.service';
|
||||||
|
import { LeaderboardScore } from '../../../src/domain/value-objects';
|
||||||
|
|
||||||
|
describe('LeaderboardCalculationService', () => {
|
||||||
|
let service: LeaderboardCalculationService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new LeaderboardCalculationService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateScore', () => {
|
||||||
|
it('should calculate score correctly', () => {
|
||||||
|
const stats = [
|
||||||
|
{ referralId: 1n, teamCount: 30 },
|
||||||
|
{ referralId: 2n, teamCount: 40 },
|
||||||
|
{ referralId: 3n, teamCount: 30 },
|
||||||
|
];
|
||||||
|
const score = service.calculateScore(100, stats);
|
||||||
|
|
||||||
|
expect(score.totalTeamCount).toBe(100);
|
||||||
|
expect(score.maxDirectTeamCount).toBe(40);
|
||||||
|
expect(score.score).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zero score for empty teams', () => {
|
||||||
|
const score = service.calculateScore(0, []);
|
||||||
|
|
||||||
|
expect(score.score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateScoreOnPlanting', () => {
|
||||||
|
it('should update score when planting added to direct referral team', () => {
|
||||||
|
const currentScore = LeaderboardScore.calculate(100, [30, 40, 30]);
|
||||||
|
const stats = [
|
||||||
|
{ referralId: 1n, teamCount: 30 },
|
||||||
|
{ referralId: 2n, teamCount: 40 },
|
||||||
|
{ referralId: 3n, teamCount: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const newScore = service.updateScoreOnPlanting(currentScore, 10, 1n, stats);
|
||||||
|
|
||||||
|
// New total = 110, new max = 40 (unchanged), score = 70
|
||||||
|
expect(newScore.totalTeamCount).toBe(110);
|
||||||
|
expect(newScore.score).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update score when max team increases', () => {
|
||||||
|
const currentScore = LeaderboardScore.calculate(100, [30, 40, 30]);
|
||||||
|
const stats = [
|
||||||
|
{ referralId: 1n, teamCount: 30 },
|
||||||
|
{ referralId: 2n, teamCount: 40 },
|
||||||
|
{ referralId: 3n, teamCount: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const newScore = service.updateScoreOnPlanting(currentScore, 20, 2n, stats);
|
||||||
|
|
||||||
|
// New total = 120, new max = 60, score = 60
|
||||||
|
expect(newScore.totalTeamCount).toBe(120);
|
||||||
|
expect(newScore.maxDirectTeamCount).toBe(60);
|
||||||
|
expect(newScore.score).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareRank', () => {
|
||||||
|
it('should correctly compare for ranking', () => {
|
||||||
|
const scoreA = LeaderboardScore.calculate(100, [40, 30, 30]); // score = 60
|
||||||
|
const scoreB = LeaderboardScore.calculate(80, [30, 30, 20]); // score = 50
|
||||||
|
|
||||||
|
expect(service.compareRank(scoreA, scoreB)).toBeLessThan(0); // A ranks higher
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateScore', () => {
|
||||||
|
it('should return true for valid score', () => {
|
||||||
|
const score = LeaderboardScore.calculate(100, [40, 30, 30]);
|
||||||
|
expect(service.validateScore(score, 100, 40)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid score', () => {
|
||||||
|
const score = LeaderboardScore.calculate(100, [40, 30, 30]);
|
||||||
|
expect(service.validateScore(score, 100, 50)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ReferralChainService } from '../../../src/domain/services/referral-chain.service';
|
||||||
|
|
||||||
|
describe('ReferralChainService', () => {
|
||||||
|
let service: ReferralChainService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new ReferralChainService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildChain', () => {
|
||||||
|
it('should build empty chain without referrer', () => {
|
||||||
|
const chain = service.buildChain(null);
|
||||||
|
expect(chain.chain).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build chain with referrer', () => {
|
||||||
|
const chain = service.buildChain(100n, [200n, 300n]);
|
||||||
|
expect(chain.chain).toEqual([100n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateChain', () => {
|
||||||
|
it('should return true for valid chain', () => {
|
||||||
|
const chain = [100n, 200n, 300n];
|
||||||
|
expect(service.validateChain(chain, 400n)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if user already in chain (circular)', () => {
|
||||||
|
const chain = [100n, 200n, 300n];
|
||||||
|
expect(service.validateChain(chain, 200n)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if chain exceeds max depth', () => {
|
||||||
|
const chain = Array.from({ length: 10 }, (_, i) => BigInt(i + 1));
|
||||||
|
expect(service.validateChain(chain, 100n)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAncestorsForUpdate', () => {
|
||||||
|
it('should return all ancestors when no limit', () => {
|
||||||
|
const chain = [100n, 200n, 300n];
|
||||||
|
expect(service.getAncestorsForUpdate(chain)).toEqual([100n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return limited ancestors when limit specified', () => {
|
||||||
|
const chain = [100n, 200n, 300n, 400n, 500n];
|
||||||
|
expect(service.getAncestorsForUpdate(chain, 3)).toEqual([100n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLevelInChain', () => {
|
||||||
|
it('should return correct level for ancestor', () => {
|
||||||
|
const chain = [100n, 200n, 300n];
|
||||||
|
expect(service.getLevelInChain(chain, 100n)).toBe(0);
|
||||||
|
expect(service.getLevelInChain(chain, 200n)).toBe(1);
|
||||||
|
expect(service.getLevelInChain(chain, 300n)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -1 if ancestor not in chain', () => {
|
||||||
|
const chain = [100n, 200n, 300n];
|
||||||
|
expect(service.getLevelInChain(chain, 999n)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { LeaderboardScore } from '../../../src/domain/value-objects/leaderboard-score.vo';
|
||||||
|
|
||||||
|
describe('LeaderboardScore Value Object', () => {
|
||||||
|
describe('calculate', () => {
|
||||||
|
it('should calculate score correctly with single team', () => {
|
||||||
|
const score = LeaderboardScore.calculate(100, [100]);
|
||||||
|
expect(score.totalTeamCount).toBe(100);
|
||||||
|
expect(score.maxDirectTeamCount).toBe(100);
|
||||||
|
expect(score.score).toBe(0); // 100 - 100 = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate score correctly with multiple teams', () => {
|
||||||
|
const score = LeaderboardScore.calculate(100, [30, 40, 30]);
|
||||||
|
expect(score.totalTeamCount).toBe(100);
|
||||||
|
expect(score.maxDirectTeamCount).toBe(40);
|
||||||
|
expect(score.score).toBe(60); // 100 - 40 = 60
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate score correctly with no teams', () => {
|
||||||
|
const score = LeaderboardScore.calculate(0, []);
|
||||||
|
expect(score.totalTeamCount).toBe(0);
|
||||||
|
expect(score.maxDirectTeamCount).toBe(0);
|
||||||
|
expect(score.score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return negative score', () => {
|
||||||
|
const score = LeaderboardScore.calculate(50, [80]);
|
||||||
|
expect(score.score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encourage balanced teams', () => {
|
||||||
|
// 不均衡: 100 total, max 80 -> score = 20
|
||||||
|
const unbalanced = LeaderboardScore.calculate(100, [80, 10, 10]);
|
||||||
|
// 均衡: 100 total, max 34 -> score = 66
|
||||||
|
const balanced = LeaderboardScore.calculate(100, [34, 33, 33]);
|
||||||
|
|
||||||
|
expect(balanced.score).toBeGreaterThan(unbalanced.score);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zero', () => {
|
||||||
|
it('should create zero score', () => {
|
||||||
|
const score = LeaderboardScore.zero();
|
||||||
|
expect(score.totalTeamCount).toBe(0);
|
||||||
|
expect(score.maxDirectTeamCount).toBe(0);
|
||||||
|
expect(score.score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recalculate', () => {
|
||||||
|
it('should recalculate with new values', () => {
|
||||||
|
const initial = LeaderboardScore.calculate(50, [30, 20]);
|
||||||
|
const updated = initial.recalculate(100, [50, 50]);
|
||||||
|
|
||||||
|
expect(updated.totalTeamCount).toBe(100);
|
||||||
|
expect(updated.maxDirectTeamCount).toBe(50);
|
||||||
|
expect(updated.score).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareTo', () => {
|
||||||
|
it('should compare scores for ranking (descending)', () => {
|
||||||
|
const scoreA = LeaderboardScore.calculate(100, [30, 30, 40]);
|
||||||
|
const scoreB = LeaderboardScore.calculate(80, [20, 20, 40]);
|
||||||
|
|
||||||
|
// scoreA.score = 60, scoreB.score = 40
|
||||||
|
// compareTo returns other.score - this.score for descending order
|
||||||
|
expect(scoreA.compareTo(scoreB)).toBeLessThan(0); // A ranks higher
|
||||||
|
expect(scoreB.compareTo(scoreA)).toBeGreaterThan(0); // B ranks lower
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for equal scores', () => {
|
||||||
|
const scoreA = LeaderboardScore.calculate(100, [50, 50]);
|
||||||
|
const scoreB = LeaderboardScore.calculate(100, [50, 50]);
|
||||||
|
|
||||||
|
expect(scoreA.compareTo(scoreB)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { ProvinceCityDistribution } from '../../../src/domain/value-objects/province-city-distribution.vo';
|
||||||
|
|
||||||
|
describe('ProvinceCityDistribution Value Object', () => {
|
||||||
|
describe('empty', () => {
|
||||||
|
it('should create empty distribution', () => {
|
||||||
|
const dist = ProvinceCityDistribution.empty();
|
||||||
|
expect(dist.getTotal()).toBe(0);
|
||||||
|
expect(dist.getAll()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromJson', () => {
|
||||||
|
it('should create distribution from JSON', () => {
|
||||||
|
const json = {
|
||||||
|
'110000': { '110100': 10, '110200': 5 },
|
||||||
|
'120000': { '120100': 8 },
|
||||||
|
};
|
||||||
|
const dist = ProvinceCityDistribution.fromJson(json);
|
||||||
|
|
||||||
|
expect(dist.getProvinceTotal('110000')).toBe(15);
|
||||||
|
expect(dist.getProvinceTotal('120000')).toBe(8);
|
||||||
|
expect(dist.getCityTotal('110000', '110100')).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty distribution for null', () => {
|
||||||
|
const dist = ProvinceCityDistribution.fromJson(null);
|
||||||
|
expect(dist.getTotal()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('add', () => {
|
||||||
|
it('should add to existing city', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('110000', '110100', 3);
|
||||||
|
|
||||||
|
expect(dist.getCityTotal('110000', '110100')).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add new province and city', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('120000', '120100', 3);
|
||||||
|
|
||||||
|
expect(dist.getProvinceTotal('110000')).toBe(5);
|
||||||
|
expect(dist.getProvinceTotal('120000')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be immutable', () => {
|
||||||
|
const dist1 = ProvinceCityDistribution.empty();
|
||||||
|
const dist2 = dist1.add('110000', '110100', 5);
|
||||||
|
|
||||||
|
expect(dist1.getTotal()).toBe(0);
|
||||||
|
expect(dist2.getTotal()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProvinceTotal', () => {
|
||||||
|
it('should return province total', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('110000', '110200', 3);
|
||||||
|
|
||||||
|
expect(dist.getProvinceTotal('110000')).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for non-existent province', () => {
|
||||||
|
const dist = ProvinceCityDistribution.empty();
|
||||||
|
expect(dist.getProvinceTotal('999999')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCityTotal', () => {
|
||||||
|
it('should return city total', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
|
||||||
|
expect(dist.getCityTotal('110000', '110100')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for non-existent city', () => {
|
||||||
|
const dist = ProvinceCityDistribution.empty();
|
||||||
|
expect(dist.getCityTotal('110000', '110100')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAll', () => {
|
||||||
|
it('should return all province/city counts', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('110000', '110200', 3);
|
||||||
|
dist = dist.add('120000', '120100', 8);
|
||||||
|
|
||||||
|
const all = dist.getAll();
|
||||||
|
expect(all.length).toBe(3);
|
||||||
|
expect(all).toContainEqual({ provinceCode: '110000', cityCode: '110100', count: 5 });
|
||||||
|
expect(all).toContainEqual({ provinceCode: '110000', cityCode: '110200', count: 3 });
|
||||||
|
expect(all).toContainEqual({ provinceCode: '120000', cityCode: '120100', count: 8 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTotal', () => {
|
||||||
|
it('should return total count', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('110000', '110200', 3);
|
||||||
|
dist = dist.add('120000', '120100', 8);
|
||||||
|
|
||||||
|
expect(dist.getTotal()).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toJson', () => {
|
||||||
|
it('should convert to JSON format', () => {
|
||||||
|
let dist = ProvinceCityDistribution.empty();
|
||||||
|
dist = dist.add('110000', '110100', 5);
|
||||||
|
dist = dist.add('110000', '110200', 3);
|
||||||
|
|
||||||
|
const json = dist.toJson();
|
||||||
|
expect(json).toEqual({
|
||||||
|
'110000': { '110100': 5, '110200': 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { ReferralChain } from '../../../src/domain/value-objects/referral-chain.vo';
|
||||||
|
|
||||||
|
describe('ReferralChain Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create empty chain when no referrer', () => {
|
||||||
|
const chain = ReferralChain.create(null);
|
||||||
|
expect(chain.chain).toEqual([]);
|
||||||
|
expect(chain.depth).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create chain with referrer', () => {
|
||||||
|
const chain = ReferralChain.create(100n);
|
||||||
|
expect(chain.chain).toEqual([100n]);
|
||||||
|
expect(chain.depth).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create chain with parent chain', () => {
|
||||||
|
const parentChain = [200n, 300n, 400n];
|
||||||
|
const chain = ReferralChain.create(100n, parentChain);
|
||||||
|
expect(chain.chain).toEqual([100n, 200n, 300n, 400n]);
|
||||||
|
expect(chain.depth).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate chain to MAX_DEPTH', () => {
|
||||||
|
const parentChain = [2n, 3n, 4n, 5n, 6n, 7n, 8n, 9n, 10n, 11n];
|
||||||
|
const chain = ReferralChain.create(1n, parentChain);
|
||||||
|
expect(chain.chain.length).toBe(10);
|
||||||
|
expect(chain.chain[0]).toBe(1n);
|
||||||
|
expect(chain.chain[9]).toBe(10n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromArray', () => {
|
||||||
|
it('should create chain from array', () => {
|
||||||
|
const chain = ReferralChain.fromArray([1n, 2n, 3n]);
|
||||||
|
expect(chain.chain).toEqual([1n, 2n, 3n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if array exceeds MAX_DEPTH', () => {
|
||||||
|
const longArray = Array.from({ length: 11 }, (_, i) => BigInt(i + 1));
|
||||||
|
expect(() => ReferralChain.fromArray(longArray)).toThrow('推荐链深度不能超过 10');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty', () => {
|
||||||
|
it('should create empty chain', () => {
|
||||||
|
const chain = ReferralChain.empty();
|
||||||
|
expect(chain.chain).toEqual([]);
|
||||||
|
expect(chain.depth).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('directReferrer', () => {
|
||||||
|
it('should return direct referrer', () => {
|
||||||
|
const chain = ReferralChain.create(100n, [200n, 300n]);
|
||||||
|
expect(chain.directReferrer).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty chain', () => {
|
||||||
|
const chain = ReferralChain.empty();
|
||||||
|
expect(chain.directReferrer).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReferrerAtLevel', () => {
|
||||||
|
it('should return referrer at specified level', () => {
|
||||||
|
const chain = ReferralChain.fromArray([100n, 200n, 300n]);
|
||||||
|
expect(chain.getReferrerAtLevel(0)).toBe(100n);
|
||||||
|
expect(chain.getReferrerAtLevel(1)).toBe(200n);
|
||||||
|
expect(chain.getReferrerAtLevel(2)).toBe(300n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for out of bounds level', () => {
|
||||||
|
const chain = ReferralChain.fromArray([100n, 200n]);
|
||||||
|
expect(chain.getReferrerAtLevel(5)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllAncestors', () => {
|
||||||
|
it('should return all ancestors', () => {
|
||||||
|
const chain = ReferralChain.fromArray([100n, 200n, 300n]);
|
||||||
|
expect(chain.getAllAncestors()).toEqual([100n, 200n, 300n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return copy of array', () => {
|
||||||
|
const chain = ReferralChain.fromArray([100n, 200n]);
|
||||||
|
const ancestors = chain.getAllAncestors();
|
||||||
|
ancestors.push(999n);
|
||||||
|
expect(chain.getAllAncestors()).toEqual([100n, 200n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ReferralCode } from '../../../src/domain/value-objects/referral-code.vo';
|
||||||
|
|
||||||
|
describe('ReferralCode Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create ReferralCode from valid string', () => {
|
||||||
|
const code = ReferralCode.create('RWATEST123');
|
||||||
|
expect(code.value).toBe('RWATEST123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert lowercase to uppercase', () => {
|
||||||
|
const code = ReferralCode.create('rwatest123');
|
||||||
|
expect(code.value).toBe('RWATEST123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for too short code', () => {
|
||||||
|
expect(() => ReferralCode.create('ABC')).toThrow('推荐码长度必须在6-20个字符之间');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for too long code', () => {
|
||||||
|
expect(() => ReferralCode.create('A'.repeat(21))).toThrow('推荐码长度必须在6-20个字符之间');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid characters', () => {
|
||||||
|
expect(() => ReferralCode.create('RWA-TEST')).toThrow('推荐码只能包含大写字母和数字');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generate', () => {
|
||||||
|
it('should generate referral code with RWA prefix', () => {
|
||||||
|
const code = ReferralCode.generate(123456789n);
|
||||||
|
expect(code.value).toMatch(/^RWA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate valid referral code', () => {
|
||||||
|
const code = ReferralCode.generate(123456789n);
|
||||||
|
expect(code.value.length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(code.value.length).toBeLessThanOrEqual(20);
|
||||||
|
expect(code.value).toMatch(/^[A-Z0-9]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different codes for same user (due to random component)', () => {
|
||||||
|
const code1 = ReferralCode.generate(123n);
|
||||||
|
const code2 = ReferralCode.generate(123n);
|
||||||
|
// Note: there's a small chance they could be the same, but very unlikely
|
||||||
|
// This test verifies the random component is working
|
||||||
|
expect(code1.value).toBeDefined();
|
||||||
|
expect(code2.value).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for equal codes', () => {
|
||||||
|
const code1 = ReferralCode.create('RWATEST123');
|
||||||
|
const code2 = ReferralCode.create('RWATEST123');
|
||||||
|
expect(code1.equals(code2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different codes', () => {
|
||||||
|
const code1 = ReferralCode.create('RWATEST123');
|
||||||
|
const code2 = ReferralCode.create('RWATEST456');
|
||||||
|
expect(code1.equals(code2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { UserId } from '../../../src/domain/value-objects/user-id.vo';
|
||||||
|
|
||||||
|
describe('UserId Value Object', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create UserId from bigint', () => {
|
||||||
|
const userId = UserId.create(123n);
|
||||||
|
expect(userId.value).toBe(123n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create UserId from string', () => {
|
||||||
|
const userId = UserId.create('456');
|
||||||
|
expect(userId.value).toBe(456n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create UserId from number', () => {
|
||||||
|
const userId = UserId.create(789);
|
||||||
|
expect(userId.value).toBe(789n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for zero value', () => {
|
||||||
|
expect(() => UserId.create(0n)).toThrow('用户ID必须大于0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for negative value', () => {
|
||||||
|
expect(() => UserId.create(-1n)).toThrow('用户ID必须大于0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for equal values', () => {
|
||||||
|
const userId1 = UserId.create(123n);
|
||||||
|
const userId2 = UserId.create(123n);
|
||||||
|
expect(userId1.equals(userId2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const userId1 = UserId.create(123n);
|
||||||
|
const userId2 = UserId.create(456n);
|
||||||
|
expect(userId1.equals(userId2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const userId = UserId.create(123n);
|
||||||
|
expect(userId.toString()).toBe('123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { AppModule } from '../../src/app.module';
|
||||||
|
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
||||||
|
import { KafkaService } from '../../src/infrastructure/messaging/kafka.service';
|
||||||
|
import { RedisService } from '../../src/infrastructure/cache/redis.service';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
// Mock services to avoid real connections in E2E tests
|
||||||
|
class MockPrismaService {
|
||||||
|
async onModuleInit() {}
|
||||||
|
async onModuleDestroy() {}
|
||||||
|
$connect = jest.fn();
|
||||||
|
$disconnect = jest.fn();
|
||||||
|
referralRelationship = {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
teamStatistics = {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
directReferral = {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
};
|
||||||
|
$transaction = jest.fn((fn) => fn(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockKafkaService {
|
||||||
|
async onModuleInit() {}
|
||||||
|
async onModuleDestroy() {}
|
||||||
|
publish = jest.fn();
|
||||||
|
publishBatch = jest.fn();
|
||||||
|
subscribe = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockRedisService {
|
||||||
|
async onModuleInit() {}
|
||||||
|
async onModuleDestroy() {}
|
||||||
|
get = jest.fn().mockResolvedValue(null);
|
||||||
|
set = jest.fn();
|
||||||
|
del = jest.fn();
|
||||||
|
zadd = jest.fn();
|
||||||
|
zrevrank = jest.fn().mockResolvedValue(null);
|
||||||
|
zrevrange = jest.fn().mockResolvedValue([]);
|
||||||
|
zrevrangeWithScores = jest.fn().mockResolvedValue([]);
|
||||||
|
zincrby = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Referral Service (E2E)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let mockPrisma: MockPrismaService;
|
||||||
|
const JWT_SECRET = 'test-jwt-secret-for-e2e-tests';
|
||||||
|
|
||||||
|
const generateToken = (userId: bigint) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: `user-${userId}`, userId: userId.toString(), type: 'access' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mockPrisma = new MockPrismaService();
|
||||||
|
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
})
|
||||||
|
.overrideProvider(PrismaService)
|
||||||
|
.useValue(mockPrisma)
|
||||||
|
.overrideProvider(KafkaService)
|
||||||
|
.useClass(MockKafkaService)
|
||||||
|
.overrideProvider(RedisService)
|
||||||
|
.useClass(MockRedisService)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Health Endpoints', () => {
|
||||||
|
it('GET /api/v1/health - should return health status', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.service).toBe('referral-service');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/health/ready - should return ready status', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health/ready')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.status).toBe('ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/health/live - should return alive status', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/health/live')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.status).toBe('alive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Referral Endpoints', () => {
|
||||||
|
describe('GET /api/v1/referral/validate/:code', () => {
|
||||||
|
it('should return valid=true for existing referral code', async () => {
|
||||||
|
mockPrisma.referralRelationship.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 1n,
|
||||||
|
userId: 100n,
|
||||||
|
myReferralCode: 'RWATEST123',
|
||||||
|
referrerId: null,
|
||||||
|
ancestorPath: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/referral/validate/RWATEST123')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
expect(res.body.referrerId).toBe('100');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return valid=false for non-existing referral code', async () => {
|
||||||
|
mockPrisma.referralRelationship.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/referral/validate/NONEXISTENT')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/referral/validate', () => {
|
||||||
|
it('should validate referral code via POST', async () => {
|
||||||
|
mockPrisma.referralRelationship.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 1n,
|
||||||
|
userId: 100n,
|
||||||
|
myReferralCode: 'RWATEST123',
|
||||||
|
referrerId: null,
|
||||||
|
ancestorPath: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/api/v1/referral/validate')
|
||||||
|
.send({ code: 'RWATEST123' })
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid code format', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/api/v1/referral/validate')
|
||||||
|
.send({ code: 'abc' }) // Too short
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/referral/me (Protected)', () => {
|
||||||
|
it('should return 401 without token', () => {
|
||||||
|
return request(app.getHttpServer()).get('/api/v1/referral/me').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user referral info with valid token', async () => {
|
||||||
|
const userId = 100n;
|
||||||
|
const token = generateToken(userId);
|
||||||
|
|
||||||
|
mockPrisma.referralRelationship.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 1n,
|
||||||
|
userId,
|
||||||
|
myReferralCode: 'RWATEST123',
|
||||||
|
referrerId: null,
|
||||||
|
ancestorPath: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.teamStatistics.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 1n,
|
||||||
|
userId,
|
||||||
|
directReferralCount: 5,
|
||||||
|
totalTeamCount: 100,
|
||||||
|
selfPlantingCount: 10,
|
||||||
|
totalTeamPlantingCount: 90,
|
||||||
|
effectivePlantingCountForRanking: 60,
|
||||||
|
maxSingleTeamPlantingCount: 40,
|
||||||
|
provinceCityDistribution: {},
|
||||||
|
lastCalcAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.directReferral.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/referral/me')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.userId).toBe('100');
|
||||||
|
expect(res.body.referralCode).toBe('RWATEST123');
|
||||||
|
expect(res.body.directReferralCount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Leaderboard Endpoints', () => {
|
||||||
|
describe('GET /api/v1/leaderboard', () => {
|
||||||
|
it('should return leaderboard entries', async () => {
|
||||||
|
mockPrisma.teamStatistics.findMany.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
userId: 100n,
|
||||||
|
effectivePlantingCountForRanking: 100,
|
||||||
|
totalTeamPlantingCount: 150,
|
||||||
|
directReferralCount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 101n,
|
||||||
|
effectivePlantingCountForRanking: 80,
|
||||||
|
totalTeamPlantingCount: 100,
|
||||||
|
directReferralCount: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/leaderboard')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.entries).toBeDefined();
|
||||||
|
expect(res.body.entries.length).toBe(2);
|
||||||
|
expect(res.body.entries[0].rank).toBe(1);
|
||||||
|
expect(res.body.entries[0].score).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support pagination', async () => {
|
||||||
|
mockPrisma.teamStatistics.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/leaderboard?limit=10&offset=20')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.entries).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/leaderboard/me (Protected)', () => {
|
||||||
|
it('should return user rank with valid token', async () => {
|
||||||
|
const userId = 100n;
|
||||||
|
const token = generateToken(userId);
|
||||||
|
|
||||||
|
mockPrisma.teamStatistics.findUnique.mockResolvedValueOnce({
|
||||||
|
effectivePlantingCountForRanking: 50,
|
||||||
|
});
|
||||||
|
mockPrisma.teamStatistics.count.mockResolvedValueOnce(5);
|
||||||
|
mockPrisma.teamStatistics.findMany.mockResolvedValueOnce([
|
||||||
|
{ userId: 100n, effectivePlantingCountForRanking: 50 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/leaderboard/me')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.userId).toBe('100');
|
||||||
|
expect(res.body.rank).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Team Statistics Endpoints', () => {
|
||||||
|
describe('GET /api/v1/team-statistics/me/distribution (Protected)', () => {
|
||||||
|
it('should return province/city distribution', async () => {
|
||||||
|
const userId = 100n;
|
||||||
|
const token = generateToken(userId);
|
||||||
|
|
||||||
|
mockPrisma.teamStatistics.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 1n,
|
||||||
|
userId,
|
||||||
|
directReferralCount: 5,
|
||||||
|
totalTeamCount: 100,
|
||||||
|
selfPlantingCount: 10,
|
||||||
|
totalTeamPlantingCount: 90,
|
||||||
|
effectivePlantingCountForRanking: 60,
|
||||||
|
maxSingleTeamPlantingCount: 40,
|
||||||
|
provinceCityDistribution: {
|
||||||
|
'110000': { '110100': 50, '110200': 30 },
|
||||||
|
'120000': { '120100': 20 },
|
||||||
|
},
|
||||||
|
lastCalcAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.directReferral.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/team-statistics/me/distribution')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.provinces).toBeDefined();
|
||||||
|
expect(res.body.totalCount).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should return 404 for non-existent routes', () => {
|
||||||
|
return request(app.getHttpServer()).get('/api/v1/nonexistent').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for protected routes without auth', () => {
|
||||||
|
return request(app.getHttpServer()).get('/api/v1/referral/me').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for invalid token', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/v1/referral/me')
|
||||||
|
.set('Authorization', 'Bearer invalid-token')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
/**
|
||||||
|
* Prisma Mock for Integration Tests
|
||||||
|
* 不依赖真实数据库的模拟实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MockReferralRelationship = {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
referrerId: bigint | null;
|
||||||
|
rootUserId: bigint | null;
|
||||||
|
myReferralCode: string;
|
||||||
|
usedReferralCode: string | null;
|
||||||
|
ancestorPath: bigint[];
|
||||||
|
depth: number;
|
||||||
|
directReferralCount: number;
|
||||||
|
activeDirectCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockTeamStatistics = {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
directReferralCount: number;
|
||||||
|
totalTeamCount: number;
|
||||||
|
selfPlantingCount: number;
|
||||||
|
totalTeamPlantingCount: number;
|
||||||
|
effectivePlantingCountForRanking: number;
|
||||||
|
maxSingleTeamPlantingCount: number;
|
||||||
|
provinceCityDistribution: Record<string, Record<string, number>>;
|
||||||
|
lastCalcAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockDirectReferral = {
|
||||||
|
id: bigint;
|
||||||
|
referrerId: bigint;
|
||||||
|
referralId: bigint;
|
||||||
|
referralSequence: bigint;
|
||||||
|
teamPlantingCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MockPrismaService {
|
||||||
|
private _referralRelationshipsStore: Map<bigint, MockReferralRelationship> = new Map();
|
||||||
|
private _teamStatisticsStore: Map<bigint, MockTeamStatistics> = new Map();
|
||||||
|
private _directReferralsStore: Map<string, MockDirectReferral> = new Map();
|
||||||
|
private idCounter = 1n;
|
||||||
|
|
||||||
|
// ReferralRelationship operations
|
||||||
|
referralRelationship = {
|
||||||
|
upsert: async (args: {
|
||||||
|
where: { userId: bigint };
|
||||||
|
create: Partial<MockReferralRelationship>;
|
||||||
|
update: Partial<MockReferralRelationship>;
|
||||||
|
}) => {
|
||||||
|
const existing = this._referralRelationshipsStore.get(args.where.userId);
|
||||||
|
if (existing) {
|
||||||
|
const updated = { ...existing, ...args.update, updatedAt: new Date() };
|
||||||
|
this._referralRelationshipsStore.set(args.where.userId, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const created: MockReferralRelationship = {
|
||||||
|
id: this.idCounter++,
|
||||||
|
userId: args.where.userId,
|
||||||
|
referrerId: args.create.referrerId ?? null,
|
||||||
|
rootUserId: args.create.rootUserId ?? null,
|
||||||
|
myReferralCode: args.create.myReferralCode!,
|
||||||
|
usedReferralCode: args.create.usedReferralCode ?? null,
|
||||||
|
ancestorPath: args.create.ancestorPath ?? [],
|
||||||
|
depth: args.create.depth ?? 0,
|
||||||
|
directReferralCount: 0,
|
||||||
|
activeDirectCount: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this._referralRelationshipsStore.set(args.where.userId, created);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: async (args: { where: { userId?: bigint; myReferralCode?: string }; select?: unknown }) => {
|
||||||
|
if (args.where.userId) {
|
||||||
|
return this._referralRelationshipsStore.get(args.where.userId) ?? null;
|
||||||
|
}
|
||||||
|
if (args.where.myReferralCode) {
|
||||||
|
for (const r of this._referralRelationshipsStore.values()) {
|
||||||
|
if (r.myReferralCode === args.where.myReferralCode) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: async (args: { where?: { referrerId?: bigint }; orderBy?: unknown }) => {
|
||||||
|
const results: MockReferralRelationship[] = [];
|
||||||
|
for (const r of this._referralRelationshipsStore.values()) {
|
||||||
|
if (!args.where?.referrerId || r.referrerId === args.where.referrerId) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
count: async (args: { where: { myReferralCode?: string; userId?: bigint } }) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const r of this._referralRelationshipsStore.values()) {
|
||||||
|
if (args.where.myReferralCode && r.myReferralCode === args.where.myReferralCode) count++;
|
||||||
|
if (args.where.userId && r.userId === args.where.userId) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// TeamStatistics operations
|
||||||
|
teamStatistics = {
|
||||||
|
upsert: async (args: {
|
||||||
|
where: { userId: bigint };
|
||||||
|
create: Partial<MockTeamStatistics>;
|
||||||
|
update: Partial<MockTeamStatistics>;
|
||||||
|
}) => {
|
||||||
|
const existing = this._teamStatisticsStore.get(args.where.userId);
|
||||||
|
if (existing) {
|
||||||
|
const updated = { ...existing, ...args.update, updatedAt: new Date() };
|
||||||
|
this._teamStatisticsStore.set(args.where.userId, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const created: MockTeamStatistics = {
|
||||||
|
id: this.idCounter++,
|
||||||
|
userId: args.where.userId,
|
||||||
|
directReferralCount: args.create.directReferralCount ?? 0,
|
||||||
|
totalTeamCount: args.create.totalTeamCount ?? 0,
|
||||||
|
selfPlantingCount: args.create.selfPlantingCount ?? 0,
|
||||||
|
totalTeamPlantingCount: args.create.totalTeamPlantingCount ?? 0,
|
||||||
|
effectivePlantingCountForRanking: args.create.effectivePlantingCountForRanking ?? 0,
|
||||||
|
maxSingleTeamPlantingCount: args.create.maxSingleTeamPlantingCount ?? 0,
|
||||||
|
provinceCityDistribution: (args.create.provinceCityDistribution as Record<string, Record<string, number>>) ?? {},
|
||||||
|
lastCalcAt: args.create.lastCalcAt ?? new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this._teamStatisticsStore.set(args.where.userId, created);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (args: { data: Partial<MockTeamStatistics> }) => {
|
||||||
|
const created: MockTeamStatistics = {
|
||||||
|
id: this.idCounter++,
|
||||||
|
userId: args.data.userId!,
|
||||||
|
directReferralCount: args.data.directReferralCount ?? 0,
|
||||||
|
totalTeamCount: args.data.totalTeamCount ?? 0,
|
||||||
|
selfPlantingCount: args.data.selfPlantingCount ?? 0,
|
||||||
|
totalTeamPlantingCount: args.data.totalTeamPlantingCount ?? 0,
|
||||||
|
effectivePlantingCountForRanking: args.data.effectivePlantingCountForRanking ?? 0,
|
||||||
|
maxSingleTeamPlantingCount: args.data.maxSingleTeamPlantingCount ?? 0,
|
||||||
|
provinceCityDistribution: (args.data.provinceCityDistribution as Record<string, Record<string, number>>) ?? {},
|
||||||
|
lastCalcAt: args.data.lastCalcAt ?? new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this._teamStatisticsStore.set(created.userId, created);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: async (args: { where: { userId: bigint } }) => {
|
||||||
|
return this._teamStatisticsStore.get(args.where.userId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: async (args: {
|
||||||
|
where?: { userId?: { in: bigint[] }; effectivePlantingCountForRanking?: { gt: number } };
|
||||||
|
orderBy?: unknown[];
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
select?: unknown;
|
||||||
|
}) => {
|
||||||
|
let results = Array.from(this._teamStatisticsStore.values());
|
||||||
|
|
||||||
|
if (args.where?.userId?.in) {
|
||||||
|
results = results.filter((s) => args.where!.userId!.in.includes(s.userId));
|
||||||
|
}
|
||||||
|
if (args.where?.effectivePlantingCountForRanking?.gt !== undefined) {
|
||||||
|
results = results.filter(
|
||||||
|
(s) => s.effectivePlantingCountForRanking > args.where!.effectivePlantingCountForRanking!.gt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
results.sort((a, b) => b.effectivePlantingCountForRanking - a.effectivePlantingCountForRanking);
|
||||||
|
|
||||||
|
if (args.skip) results = results.slice(args.skip);
|
||||||
|
if (args.take) results = results.slice(0, args.take);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
count: async (args: { where: { effectivePlantingCountForRanking?: { gt: number } } }) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const s of this._teamStatisticsStore.values()) {
|
||||||
|
if (
|
||||||
|
args.where.effectivePlantingCountForRanking?.gt !== undefined &&
|
||||||
|
s.effectivePlantingCountForRanking > args.where.effectivePlantingCountForRanking.gt
|
||||||
|
) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (args: { where: { userId: bigint }; data: Partial<MockTeamStatistics> }) => {
|
||||||
|
const existing = this._teamStatisticsStore.get(args.where.userId);
|
||||||
|
if (!existing) throw new Error('Not found');
|
||||||
|
const updated = { ...existing, ...args.data, updatedAt: new Date() };
|
||||||
|
this._teamStatisticsStore.set(args.where.userId, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// DirectReferral operations
|
||||||
|
directReferral = {
|
||||||
|
upsert: async (args: {
|
||||||
|
where: { uk_referrer_referral: { referrerId: bigint; referralId: bigint } };
|
||||||
|
create: Partial<MockDirectReferral>;
|
||||||
|
update: Partial<MockDirectReferral> | { teamPlantingCount: { increment: number } };
|
||||||
|
}) => {
|
||||||
|
const key = `${args.where.uk_referrer_referral.referrerId}-${args.where.uk_referrer_referral.referralId}`;
|
||||||
|
const existing = this._directReferralsStore.get(key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
let teamPlantingCount = existing.teamPlantingCount;
|
||||||
|
if ('teamPlantingCount' in args.update) {
|
||||||
|
if (typeof args.update.teamPlantingCount === 'object' && 'increment' in args.update.teamPlantingCount) {
|
||||||
|
teamPlantingCount += args.update.teamPlantingCount.increment;
|
||||||
|
} else {
|
||||||
|
teamPlantingCount = args.update.teamPlantingCount as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = { ...existing, teamPlantingCount, updatedAt: new Date() };
|
||||||
|
this._directReferralsStore.set(key, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: MockDirectReferral = {
|
||||||
|
id: this.idCounter++,
|
||||||
|
referrerId: args.where.uk_referrer_referral.referrerId,
|
||||||
|
referralId: args.where.uk_referrer_referral.referralId,
|
||||||
|
referralSequence: args.create.referralSequence!,
|
||||||
|
teamPlantingCount: args.create.teamPlantingCount ?? 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this._directReferralsStore.set(key, created);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: async (args: { where: { referrerId: bigint }; select?: unknown }) => {
|
||||||
|
const results: MockDirectReferral[] = [];
|
||||||
|
for (const dr of this._directReferralsStore.values()) {
|
||||||
|
if (dr.referrerId === args.where.referrerId) {
|
||||||
|
results.push(dr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transaction support
|
||||||
|
$transaction = async <T>(fn: (tx: MockPrismaService) => Promise<T>): Promise<T> => {
|
||||||
|
return fn(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup for tests
|
||||||
|
$cleanup() {
|
||||||
|
this._referralRelationshipsStore.clear();
|
||||||
|
this._teamStatisticsStore.clear();
|
||||||
|
this._directReferralsStore.clear();
|
||||||
|
this.idCounter = 1n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle methods
|
||||||
|
async onModuleInit() {}
|
||||||
|
async onModuleDestroy() {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ReferralRelationshipRepository } from '../../../src/infrastructure/repositories/referral-relationship.repository';
|
||||||
|
import { PrismaService } from '../../../src/infrastructure/database/prisma.service';
|
||||||
|
import { MockPrismaService } from '../mocks/prisma.mock';
|
||||||
|
import { ReferralRelationship } from '../../../src/domain';
|
||||||
|
|
||||||
|
describe('ReferralRelationshipRepository (Integration)', () => {
|
||||||
|
let repository: ReferralRelationshipRepository;
|
||||||
|
let mockPrisma: MockPrismaService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockPrisma = new MockPrismaService();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ReferralRelationshipRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrisma,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<ReferralRelationshipRepository>(ReferralRelationshipRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockPrisma.$cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save', () => {
|
||||||
|
it('should save a new referral relationship', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
const saved = await repository.save(relationship);
|
||||||
|
|
||||||
|
expect(saved).toBeDefined();
|
||||||
|
expect(saved.userId).toBe(100n);
|
||||||
|
expect(saved.referralCode).toBeDefined();
|
||||||
|
expect(saved.referralChain).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save referral relationship with referrer', async () => {
|
||||||
|
// First create the referrer
|
||||||
|
const referrer = ReferralRelationship.create(50n, null);
|
||||||
|
await repository.save(referrer);
|
||||||
|
|
||||||
|
// Then create with referrer
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, []);
|
||||||
|
const saved = await repository.save(relationship);
|
||||||
|
|
||||||
|
expect(saved.userId).toBe(100n);
|
||||||
|
expect(saved.referrerId).toBe(50n);
|
||||||
|
expect(saved.referralChain).toContain(50n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing referral relationship', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
await repository.save(relationship);
|
||||||
|
|
||||||
|
// Save again should update
|
||||||
|
const updated = await repository.save(relationship);
|
||||||
|
expect(updated.userId).toBe(100n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByUserId', () => {
|
||||||
|
it('should find relationship by user ID', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
await repository.save(relationship);
|
||||||
|
|
||||||
|
const found = await repository.findByUserId(100n);
|
||||||
|
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found!.userId).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent user', async () => {
|
||||||
|
const found = await repository.findByUserId(999n);
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByReferralCode', () => {
|
||||||
|
it('should find relationship by referral code', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
const saved = await repository.save(relationship);
|
||||||
|
|
||||||
|
const found = await repository.findByReferralCode(saved.referralCode);
|
||||||
|
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found!.userId).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent code', async () => {
|
||||||
|
const found = await repository.findByReferralCode('NONEXISTENT');
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDirectReferrals', () => {
|
||||||
|
it('should find all direct referrals', async () => {
|
||||||
|
// Create referrer
|
||||||
|
const referrer = ReferralRelationship.create(50n, null);
|
||||||
|
await repository.save(referrer);
|
||||||
|
|
||||||
|
// Create direct referrals
|
||||||
|
const ref1 = ReferralRelationship.create(100n, 50n, []);
|
||||||
|
const ref2 = ReferralRelationship.create(101n, 50n, []);
|
||||||
|
await repository.save(ref1);
|
||||||
|
await repository.save(ref2);
|
||||||
|
|
||||||
|
const directReferrals = await repository.findDirectReferrals(50n);
|
||||||
|
|
||||||
|
expect(directReferrals.length).toBe(2);
|
||||||
|
expect(directReferrals.map((r) => r.userId)).toContain(100n);
|
||||||
|
expect(directReferrals.map((r) => r.userId)).toContain(101n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for user with no referrals', async () => {
|
||||||
|
const referrer = ReferralRelationship.create(50n, null);
|
||||||
|
await repository.save(referrer);
|
||||||
|
|
||||||
|
const directReferrals = await repository.findDirectReferrals(50n);
|
||||||
|
expect(directReferrals.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('existsByReferralCode', () => {
|
||||||
|
it('should return true for existing code', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
const saved = await repository.save(relationship);
|
||||||
|
|
||||||
|
const exists = await repository.existsByReferralCode(saved.referralCode);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent code', async () => {
|
||||||
|
const exists = await repository.existsByReferralCode('NONEXISTENT');
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('existsByUserId', () => {
|
||||||
|
it('should return true for existing user', async () => {
|
||||||
|
const relationship = ReferralRelationship.create(100n, null);
|
||||||
|
await repository.save(relationship);
|
||||||
|
|
||||||
|
const exists = await repository.existsByUserId(100n);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent user', async () => {
|
||||||
|
const exists = await repository.existsByUserId(999n);
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReferralChain', () => {
|
||||||
|
it('should return referral chain', async () => {
|
||||||
|
const parentChain = [200n, 300n];
|
||||||
|
const relationship = ReferralRelationship.create(100n, 50n, parentChain);
|
||||||
|
await repository.save(relationship);
|
||||||
|
|
||||||
|
const chain = await repository.getReferralChain(100n);
|
||||||
|
expect(chain).toContain(50n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for non-existent user', async () => {
|
||||||
|
const chain = await repository.getReferralChain(999n);
|
||||||
|
expect(chain).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TeamStatisticsRepository } from '../../../src/infrastructure/repositories/team-statistics.repository';
|
||||||
|
import { PrismaService } from '../../../src/infrastructure/database/prisma.service';
|
||||||
|
import { MockPrismaService } from '../mocks/prisma.mock';
|
||||||
|
import { TeamStatistics } from '../../../src/domain';
|
||||||
|
|
||||||
|
describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
let repository: TeamStatisticsRepository;
|
||||||
|
let mockPrisma: MockPrismaService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockPrisma = new MockPrismaService();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TeamStatisticsRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrisma,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<TeamStatisticsRepository>(TeamStatisticsRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockPrisma.$cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create new team statistics', async () => {
|
||||||
|
const stats = await repository.create(100n);
|
||||||
|
|
||||||
|
expect(stats).toBeDefined();
|
||||||
|
expect(stats.userId).toBe(100n);
|
||||||
|
expect(stats.directReferralCount).toBe(0);
|
||||||
|
expect(stats.totalTeamCount).toBe(0);
|
||||||
|
expect(stats.leaderboardScore).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save', () => {
|
||||||
|
it('should save team statistics', async () => {
|
||||||
|
const stats = TeamStatistics.create(100n);
|
||||||
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
|
const saved = await repository.save(stats);
|
||||||
|
|
||||||
|
expect(saved).toBeDefined();
|
||||||
|
expect(saved.userId).toBe(100n);
|
||||||
|
expect(saved.personalPlantingCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing statistics', async () => {
|
||||||
|
// Create initial
|
||||||
|
const stats = await repository.create(100n);
|
||||||
|
|
||||||
|
// Create new instance and add planting
|
||||||
|
const updated = TeamStatistics.create(100n);
|
||||||
|
updated.addPersonalPlanting(20, '110000', '110100');
|
||||||
|
|
||||||
|
const saved = await repository.save(updated);
|
||||||
|
expect(saved.personalPlantingCount).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByUserId', () => {
|
||||||
|
it('should find statistics by user ID', async () => {
|
||||||
|
await repository.create(100n);
|
||||||
|
|
||||||
|
const found = await repository.findByUserId(100n);
|
||||||
|
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found!.userId).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent user', async () => {
|
||||||
|
const found = await repository.findByUserId(999n);
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByUserIds', () => {
|
||||||
|
it('should find statistics for multiple users', async () => {
|
||||||
|
await repository.create(100n);
|
||||||
|
await repository.create(101n);
|
||||||
|
await repository.create(102n);
|
||||||
|
|
||||||
|
const found = await repository.findByUserIds([100n, 101n]);
|
||||||
|
|
||||||
|
expect(found.length).toBe(2);
|
||||||
|
expect(found.map((s) => s.userId)).toContain(100n);
|
||||||
|
expect(found.map((s) => s.userId)).toContain(101n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeaderboard', () => {
|
||||||
|
it('should return leaderboard sorted by score', async () => {
|
||||||
|
// Create users with different scores
|
||||||
|
const stats1 = TeamStatistics.create(100n);
|
||||||
|
stats1.addPersonalPlanting(50, '110000', '110100');
|
||||||
|
await repository.save(stats1);
|
||||||
|
|
||||||
|
const stats2 = TeamStatistics.create(101n);
|
||||||
|
stats2.addPersonalPlanting(100, '110000', '110100');
|
||||||
|
await repository.save(stats2);
|
||||||
|
|
||||||
|
const stats3 = TeamStatistics.create(102n);
|
||||||
|
stats3.addPersonalPlanting(30, '110000', '110100');
|
||||||
|
await repository.save(stats3);
|
||||||
|
|
||||||
|
const leaderboard = await repository.getLeaderboard({ limit: 10 });
|
||||||
|
|
||||||
|
expect(leaderboard.length).toBe(3);
|
||||||
|
// Should be sorted by score descending
|
||||||
|
expect(leaderboard[0].userId).toBe(101n);
|
||||||
|
expect(leaderboard[0].rank).toBe(1);
|
||||||
|
expect(leaderboard[1].userId).toBe(100n);
|
||||||
|
expect(leaderboard[1].rank).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect limit and offset', async () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const stats = TeamStatistics.create(BigInt(100 + i));
|
||||||
|
stats.addPersonalPlanting(10 + i, '110000', '110100');
|
||||||
|
await repository.save(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboard = await repository.getLeaderboard({ limit: 5, offset: 2 });
|
||||||
|
|
||||||
|
expect(leaderboard.length).toBe(5);
|
||||||
|
expect(leaderboard[0].rank).toBe(3); // 1-indexed, offset by 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserRank', () => {
|
||||||
|
it('should return correct rank', async () => {
|
||||||
|
const stats1 = TeamStatistics.create(100n);
|
||||||
|
stats1.addPersonalPlanting(100, '110000', '110100');
|
||||||
|
await repository.save(stats1);
|
||||||
|
|
||||||
|
const stats2 = TeamStatistics.create(101n);
|
||||||
|
stats2.addPersonalPlanting(50, '110000', '110100');
|
||||||
|
await repository.save(stats2);
|
||||||
|
|
||||||
|
const rank = await repository.getUserRank(101n);
|
||||||
|
expect(rank).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent user', async () => {
|
||||||
|
const rank = await repository.getUserRank(999n);
|
||||||
|
expect(rank).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchUpdateTeamCounts', () => {
|
||||||
|
it('should batch update team counts for multiple users', async () => {
|
||||||
|
await repository.create(100n);
|
||||||
|
await repository.create(101n);
|
||||||
|
|
||||||
|
await repository.batchUpdateTeamCounts([
|
||||||
|
{ userId: 100n, countDelta: 10, provinceCode: '110000', cityCode: '110100' },
|
||||||
|
{ userId: 101n, countDelta: 20, provinceCode: '120000', cityCode: '120100' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats100 = await repository.findByUserId(100n);
|
||||||
|
const stats101 = await repository.findByUserId(101n);
|
||||||
|
|
||||||
|
expect(stats100!.teamPlantingCount).toBe(10);
|
||||||
|
expect(stats101!.teamPlantingCount).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ReferralService } from '../../../src/application/services/referral.service';
|
||||||
|
import {
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
ReferralChainService,
|
||||||
|
ReferralRelationship,
|
||||||
|
} from '../../../src/domain';
|
||||||
|
import { EventPublisherService, LeaderboardCacheService } from '../../../src/infrastructure';
|
||||||
|
import { CreateReferralRelationshipCommand } from '../../../src/application/commands';
|
||||||
|
import { GetUserReferralInfoQuery, GetDirectReferralsQuery } from '../../../src/application/queries';
|
||||||
|
|
||||||
|
// Mock repository that works with domain aggregates
|
||||||
|
class MockReferralRepository {
|
||||||
|
private data = new Map<bigint, ReferralRelationship>();
|
||||||
|
private codeToUserId = new Map<string, bigint>();
|
||||||
|
|
||||||
|
async save(relationship: ReferralRelationship): Promise<ReferralRelationship> {
|
||||||
|
this.data.set(relationship.userId, relationship);
|
||||||
|
this.codeToUserId.set(relationship.referralCode, relationship.userId);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint): Promise<ReferralRelationship | null> {
|
||||||
|
return this.data.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByReferralCode(code: string): Promise<ReferralRelationship | null> {
|
||||||
|
const userId = this.codeToUserId.get(code);
|
||||||
|
if (!userId) return null;
|
||||||
|
return this.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDirectReferrals(userId: bigint): Promise<ReferralRelationship[]> {
|
||||||
|
const results: ReferralRelationship[] = [];
|
||||||
|
for (const record of this.data.values()) {
|
||||||
|
if (record.referrerId === userId) {
|
||||||
|
results.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByReferralCode(code: string): Promise<boolean> {
|
||||||
|
return this.codeToUserId.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByUserId(userId: bigint): Promise<boolean> {
|
||||||
|
return this.data.has(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReferralChain(userId: bigint): Promise<bigint[]> {
|
||||||
|
const record = this.data.get(userId);
|
||||||
|
return record?.referralChain ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockTeamStatsRepository {
|
||||||
|
private data = new Map<bigint, { userId: bigint; directReferralCount: number; totalTeamCount: number; personalPlantingCount: number; teamPlantingCount: number; leaderboardScore: number; addDirectReferral: (id: bigint) => void; getDirectReferralStats: () => Map<bigint, number> }>();
|
||||||
|
|
||||||
|
async create(userId: bigint) {
|
||||||
|
const stats = {
|
||||||
|
userId,
|
||||||
|
directReferralCount: 0,
|
||||||
|
totalTeamCount: 0,
|
||||||
|
personalPlantingCount: 0,
|
||||||
|
teamPlantingCount: 0,
|
||||||
|
leaderboardScore: 0,
|
||||||
|
addDirectReferral: function(id: bigint) { this.directReferralCount++; },
|
||||||
|
getDirectReferralStats: () => new Map<bigint, number>(),
|
||||||
|
};
|
||||||
|
this.data.set(userId, stats);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(stats: { userId: bigint }) {
|
||||||
|
const existing = this.data.get(stats.userId);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, stats);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: bigint) {
|
||||||
|
return this.data.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockEventPublisher {
|
||||||
|
async publishDomainEvents() {}
|
||||||
|
async publishEvent() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockLeaderboardCache {
|
||||||
|
async getUserRank() { return 1; }
|
||||||
|
async updateScore() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReferralService (Integration)', () => {
|
||||||
|
let service: ReferralService;
|
||||||
|
let referralRepo: MockReferralRepository;
|
||||||
|
let teamStatsRepo: MockTeamStatsRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
referralRepo = new MockReferralRepository();
|
||||||
|
teamStatsRepo = new MockTeamStatsRepository();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ReferralService,
|
||||||
|
ReferralChainService,
|
||||||
|
{
|
||||||
|
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
useValue: referralRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TEAM_STATISTICS_REPOSITORY,
|
||||||
|
useValue: teamStatsRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EventPublisherService,
|
||||||
|
useClass: MockEventPublisher,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LeaderboardCacheService,
|
||||||
|
useClass: MockLeaderboardCache,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ReferralService>(ReferralService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createReferralRelationship', () => {
|
||||||
|
it('should create referral relationship without referrer', async () => {
|
||||||
|
const command = new CreateReferralRelationshipCommand(100n, null);
|
||||||
|
const result = await service.createReferralRelationship(command);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.referralCode).toBeDefined();
|
||||||
|
expect(result.referralCode.length).toBeGreaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create referral relationship with valid referrer code', async () => {
|
||||||
|
// First create a referrer
|
||||||
|
const referrerCommand = new CreateReferralRelationshipCommand(50n, null);
|
||||||
|
const referrerResult = await service.createReferralRelationship(referrerCommand);
|
||||||
|
|
||||||
|
// Then create with referrer code
|
||||||
|
const command = new CreateReferralRelationshipCommand(100n, referrerResult.referralCode);
|
||||||
|
const result = await service.createReferralRelationship(command);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.referralCode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if user already has referral relationship', async () => {
|
||||||
|
const command = new CreateReferralRelationshipCommand(100n, null);
|
||||||
|
await service.createReferralRelationship(command);
|
||||||
|
|
||||||
|
await expect(service.createReferralRelationship(command)).rejects.toThrow('用户已存在推荐关系');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid referral code', async () => {
|
||||||
|
const command = new CreateReferralRelationshipCommand(100n, 'INVALID_CODE');
|
||||||
|
|
||||||
|
await expect(service.createReferralRelationship(command)).rejects.toThrow('推荐码不存在');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserReferralInfo', () => {
|
||||||
|
it('should return user referral info', async () => {
|
||||||
|
// Create user first
|
||||||
|
const createCommand = new CreateReferralRelationshipCommand(100n, null);
|
||||||
|
await service.createReferralRelationship(createCommand);
|
||||||
|
|
||||||
|
const query = new GetUserReferralInfoQuery(100n);
|
||||||
|
const result = await service.getUserReferralInfo(query);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.userId).toBe('100');
|
||||||
|
expect(result.referralCode).toBeDefined();
|
||||||
|
expect(result.directReferralCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for non-existent user', async () => {
|
||||||
|
const query = new GetUserReferralInfoQuery(999n);
|
||||||
|
|
||||||
|
await expect(service.getUserReferralInfo(query)).rejects.toThrow('用户推荐关系不存在');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDirectReferrals', () => {
|
||||||
|
it('should return direct referrals list', async () => {
|
||||||
|
// Create referrer
|
||||||
|
const referrerCommand = new CreateReferralRelationshipCommand(50n, null);
|
||||||
|
const referrerResult = await service.createReferralRelationship(referrerCommand);
|
||||||
|
|
||||||
|
// Create direct referrals
|
||||||
|
await service.createReferralRelationship(new CreateReferralRelationshipCommand(100n, referrerResult.referralCode));
|
||||||
|
await service.createReferralRelationship(new CreateReferralRelationshipCommand(101n, referrerResult.referralCode));
|
||||||
|
|
||||||
|
const query = new GetDirectReferralsQuery(50n);
|
||||||
|
const result = await service.getDirectReferrals(query);
|
||||||
|
|
||||||
|
expect(result.referrals.length).toBe(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateReferralCode', () => {
|
||||||
|
it('should return true for valid code', async () => {
|
||||||
|
const createCommand = new CreateReferralRelationshipCommand(100n, null);
|
||||||
|
const { referralCode } = await service.createReferralRelationship(createCommand);
|
||||||
|
|
||||||
|
const isValid = await service.validateReferralCode(referralCode);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid code', async () => {
|
||||||
|
const isValid = await service.validateReferralCode('INVALID');
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "..",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1"
|
||||||
|
},
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/setup-e2e.ts"],
|
||||||
|
"testTimeout": 60000
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue