feat(challenges): 新增分页排行榜接口并重构排行逻辑

- 新增 GET /challenges/:id/rankings 接口,支持分页查询排行榜
- 抽离 buildChallengeRankings 方法,统一排行榜数据构建逻辑
- 新增 ChallengeRankingListDto 与 GetChallengeRankingQueryDto 用于接口数据校验
- 优化挑战列表排序逻辑,按状态优先级与时间排序
- 修复排行榜索引计算错误,确保分页场景下排名正确
This commit is contained in:
richarjiang
2025-09-30 11:17:31 +08:00
parent f13953030b
commit 87c3cbfac9
3 changed files with 179 additions and 51 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, Body, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Post, Body, UseGuards, Query } from '@nestjs/common';
import { ChallengesService } from './challenges.service'; import { ChallengesService } from './challenges.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { BaseResponseDto, ResponseCode } from '../base.dto'; import { BaseResponseDto, ResponseCode } from '../base.dto';
@@ -8,6 +8,7 @@ import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'
import { ChallengeDetailDto } from './dto/challenge-detail.dto'; import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto } from './dto/challenge-progress.dto'; import { ChallengeProgressDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto';
@Controller('challenges') @Controller('challenges')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -39,6 +40,24 @@ export class ChallengesController {
}; };
} }
@Get(':id/rankings')
async getChallengeRankings(
@Param('id') id: string,
@Query() query: GetChallengeRankingQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeRankingListDto>> {
const data = await this.challengesService.getChallengeRankings(id, {
page: query.page,
pageSize: query.pageSize,
userId: user.sub,
});
return {
code: ResponseCode.SUCCESS,
message: '获取挑战排行榜成功',
data,
};
}
@Post(':id/join') @Post(':id/join')
async joinChallenge( async joinChallenge(
@Param('id') id: string, @Param('id') id: string,

View File

@@ -7,6 +7,7 @@ import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'
import { ChallengeDetailDto } from './dto/challenge-detail.dto'; import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto'; import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto } from './dto/challenge-ranking.dto';
import { fn, col, Op, UniqueConstraintError } from 'sequelize'; import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs'; import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model'; import { User } from '../users/models/user.model';
@@ -38,6 +39,30 @@ export class ChallengesService {
const challengeIds = challenges.map((challenge) => challenge.id); const challengeIds = challenges.map((challenge) => challenge.id);
const statusPriority: Record<ChallengeStatus, number> = {
[ChallengeStatus.ONGOING]: 0,
[ChallengeStatus.UPCOMING]: 1,
[ChallengeStatus.EXPIRED]: 2,
};
const challengesWithStatus = challenges
.map((challenge) => ({
challenge,
status: this.computeStatus(challenge.startAt, challenge.endAt),
}))
.sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) {
return priorityDiff;
}
if (a.challenge.startAt !== b.challenge.startAt) {
return Number(a.challenge.startAt) - Number(b.challenge.startAt);
}
return Number(a.challenge.endAt) - Number(b.challenge.endAt);
});
const participantCountsRaw = await this.participantModel.findAll({ const participantCountsRaw = await this.participantModel.findAll({
attributes: ['challengeId', [fn('COUNT', col('id')), 'count']], attributes: ['challengeId', [fn('COUNT', col('id')), 'count']],
where: { where: {
@@ -71,7 +96,7 @@ export class ChallengesService {
participationMap.set(participation.challengeId, participation); participationMap.set(participation.challengeId, participation);
} }
return challenges.map((challenge) => { return challengesWithStatus.map(({ challenge, status }) => {
const completionTarget = challenge.minimumCheckInDays const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id); const participation = participationMap.get(challenge.id);
const progress = participation const progress = participation
@@ -85,7 +110,7 @@ export class ChallengesService {
periodLabel: challenge.periodLabel, periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel, durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel, requirementLabel: challenge.requirementLabel,
status: this.computeStatus(challenge.startAt, challenge.endAt), status,
startAt: challenge.startAt, startAt: challenge.startAt,
endAt: challenge.endAt, endAt: challenge.endAt,
participantsCount: participantsCountMap.get(challenge.id) ?? 0, participantsCount: participantsCountMap.get(challenge.id) ?? 0,
@@ -142,68 +167,24 @@ export class ChallengesService {
}); });
const rankingsRaw = await this.participantModel.findAll({ const rankingResult = await this.buildChallengeRankings(challenge, { page: 1, pageSize: 10 });
where: {
challengeId,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{ model: User, attributes: ['id', 'name', 'avatar'] }],
order: [
['progressValue', 'DESC'],
['updatedAt', 'ASC'],
],
limit: 10,
});
this.winstonLogger.info('get rankingsRaw end', { this.winstonLogger.info('fetch rankings end', {
context: 'getChallengeDetail', context: 'getChallengeDetail',
userId, userId,
challengeId, challengeId,
participantsCount, participantsCount,
participation, participation,
rankingsRawCount: rankingsRaw.length, rankingsCount: rankingResult.items.length,
}); });
const today = dayjs().format('YYYY-MM-DD');
const todayReportsMap = new Map<string, number>();
if (rankingsRaw.length) {
const rankingUserIds = rankingsRaw.map((item) => item.userId);
const reports = await this.progressReportModel.findAll({
where: {
challengeId,
reportDate: today,
userId: {
[Op.in]: rankingUserIds,
},
},
});
for (const report of reports) {
todayReportsMap.set(report.userId, report.reportedValue ?? 0);
}
}
const completionTarget = challenge.minimumCheckInDays const completionTarget = challenge.minimumCheckInDays
const progress = participation const progress = participation
? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt) ? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt)
: undefined; : undefined;
const rankings: RankingItemDto[] = rankingsRaw.map((item, index) => { const rankings: RankingItemDto[] = rankingResult.items;
const itemTarget = item.targetValue && item.targetValue > 0 ? item.targetValue : completionTarget;
return {
id: item.user?.id ?? item.userId,
name: item.user?.name ?? '未知用户',
avatar: item.user?.avatar ?? null,
metric: `${item.progressValue}/${itemTarget}`,
badge: this.resolveRankingBadge(index),
todayReportedValue: todayReportsMap.get(item.userId) ?? 0,
todayTargetValue: challenge.targetValue,
};
});
const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined; const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined;
@@ -228,6 +209,49 @@ export class ChallengesService {
}; };
} }
async getChallengeRankings(
challengeId: string,
params: { page?: number; pageSize?: number; userId?: string } = {},
): Promise<ChallengeRankingListDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
const { userId } = params;
const page = params.page && params.page > 0 ? params.page : 1;
const requestedPageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 20;
const pageSize = Math.min(requestedPageSize, 100);
this.winstonLogger.info('get challenge rankings start', {
context: 'getChallengeRankings',
challengeId,
userId,
page,
pageSize,
});
const rankingResult = await this.buildChallengeRankings(challenge, { page, pageSize });
this.winstonLogger.info('get challenge rankings end', {
context: 'getChallengeRankings',
challengeId,
userId,
page,
pageSize,
total: rankingResult.total,
itemsCount: rankingResult.items.length,
});
return {
total: rankingResult.total,
page,
pageSize,
items: rankingResult.items,
};
}
async joinChallenge(userId: string, challengeId: string): Promise<ChallengeProgressDto> { async joinChallenge(userId: string, challengeId: string): Promise<ChallengeProgressDto> {
const challenge = await this.challengeModel.findByPk(challengeId); const challenge = await this.challengeModel.findByPk(challengeId);
@@ -483,4 +507,66 @@ export class ChallengesService {
return higherProgressCount + 1; return higherProgressCount + 1;
} }
private async buildChallengeRankings(
challenge: Challenge,
params: { page: number; pageSize: number },
): Promise<{ items: RankingItemDto[]; total: number }> {
const { page, pageSize } = params;
const offset = (page - 1) * pageSize;
const { rows, count } = await this.participantModel.findAndCountAll({
where: {
challengeId: challenge.id,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{ model: User, attributes: ['id', 'name', 'avatar'] }],
order: [
['progressValue', 'DESC'],
['updatedAt', 'ASC'],
],
limit: pageSize,
offset,
});
const today = dayjs().format('YYYY-MM-DD');
const todayReportsMap = new Map<string, number>();
if (rows.length) {
const reports = await this.progressReportModel.findAll({
where: {
challengeId: challenge.id,
reportDate: today,
userId: {
[Op.in]: rows.map((item) => item.userId),
},
},
});
for (const report of reports) {
todayReportsMap.set(report.userId, report.reportedValue ?? 0);
}
}
const completionTarget = challenge.minimumCheckInDays
const items = rows.map((item, index) => {
const listIndex = offset + index;
const itemTarget = item.targetValue && item.targetValue > 0 ? item.targetValue : completionTarget;
return {
id: item.user?.id ?? item.userId,
name: item.user?.name ?? '未知用户',
avatar: item.user?.avatar ?? null,
metric: `${item.progressValue}/${itemTarget}`,
badge: this.resolveRankingBadge(listIndex),
todayReportedValue: todayReportsMap.get(item.userId) ?? 0,
todayTargetValue: challenge.targetValue,
};
});
return {
items,
total: count,
};
}
} }

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { RankingItemDto } from './challenge-progress.dto';
export class GetChallengeRankingQueryDto {
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
export interface ChallengeRankingListDto {
total: number;
page: number;
pageSize: number;
items: RankingItemDto[];
}