refactor(referral): remove legacy leaderboard code

Remove duplicate leaderboard functionality from referral-service.
All leaderboard features should now go through leaderboard-service.

Removed files:
- api/controllers/leaderboard.controller.ts
- api/dto/leaderboard.dto.ts
- application/queries/get-leaderboard.query.ts
- infrastructure/cache/leaderboard-cache.service.ts
- Related test files

Modified:
- TeamStatisticsService: removed getLeaderboard() and getUserRank()
- Module registrations updated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-09 22:05:48 -08:00
parent 594f7880c3
commit 2bf5ca933e
13 changed files with 1 additions and 488 deletions

View File

@ -1,4 +1,3 @@
export * from './referral.controller';
export * from './leaderboard.controller';
export * from './team-statistics.controller';
export * from './health.controller';

View File

@ -1,69 +0,0 @@
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,
};
}
}

View File

@ -1,3 +1,2 @@
export * from './referral.dto';
export * from './leaderboard.dto';
export * from './team-statistics.dto';

View File

@ -1,56 +0,0 @@
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;
}

View File

@ -1,20 +0,0 @@
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;
}

View File

@ -1,4 +1,3 @@
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';

View File

@ -6,11 +6,9 @@ import {
ITeamStatisticsRepository,
ReferralChainService,
} from '../../domain';
import { EventPublisherService, LeaderboardCacheService } from '../../infrastructure';
import { EventPublisherService } from '../../infrastructure';
import { UpdateTeamStatisticsCommand } from '../commands';
import {
GetLeaderboardQuery,
LeaderboardResult,
GetProvinceCityDistributionQuery,
ProvinceCityDistributionResult,
} from '../queries';
@ -26,7 +24,6 @@ export class TeamStatisticsService {
private readonly teamStatsRepo: ITeamStatisticsRepository,
private readonly referralChainService: ReferralChainService,
private readonly eventPublisher: EventPublisherService,
private readonly leaderboardCache: LeaderboardCacheService,
) {}
/**
@ -45,7 +42,6 @@ export class TeamStatisticsService {
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();
}
@ -65,7 +61,6 @@ export class TeamStatisticsService {
// 第一级上级 (直接推荐人) 的 fromDirectReferralId 是当前用户
// 第二级及以上的 fromDirectReferralId 是第一级上级
let currentSource = command.userId;
for (let i = 0; i < ancestors.length; i++) {
const ancestorId = ancestors[i];
@ -81,90 +76,11 @@ export class TeamStatisticsService {
// 批量更新
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);
}
/**
*
*/

View File

@ -1,2 +1 @@
export * from './redis.service';
export * from './leaderboard-cache.service';

View File

@ -1,85 +0,0 @@
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');
}
}

View File

@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config';
import { ApplicationModule } from './application.module';
import {
ReferralController,
LeaderboardController,
TeamStatisticsController,
HealthController,
} from '../api';
@ -13,7 +12,6 @@ import { InternalReferralController } from '../api/controllers/referral.controll
imports: [ConfigModule, ApplicationModule],
controllers: [
ReferralController,
LeaderboardController,
TeamStatisticsController,
HealthController,
InternalReferralController,

View File

@ -7,7 +7,6 @@ import {
KafkaService,
EventPublisherService,
RedisService,
LeaderboardCacheService,
} from '../infrastructure';
import {
REFERRAL_RELATIONSHIP_REPOSITORY,
@ -22,7 +21,6 @@ import {
KafkaService,
RedisService,
EventPublisherService,
LeaderboardCacheService,
{
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
useClass: ReferralRelationshipRepository,
@ -37,7 +35,6 @@ import {
KafkaService,
RedisService,
EventPublisherService,
LeaderboardCacheService,
REFERRAL_RELATIONSHIP_REPOSITORY,
TEAM_STATISTICS_REPOSITORY,
],

View File

@ -1,85 +0,0 @@
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);
});
});
});

View File

@ -1,79 +0,0 @@
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);
});
});
});