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

@@ -7,6 +7,7 @@ import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'
import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto } from './dto/challenge-ranking.dto';
import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model';
@@ -38,6 +39,30 @@ export class ChallengesService {
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({
attributes: ['challengeId', [fn('COUNT', col('id')), 'count']],
where: {
@@ -71,7 +96,7 @@ export class ChallengesService {
participationMap.set(participation.challengeId, participation);
}
return challenges.map((challenge) => {
return challengesWithStatus.map(({ challenge, status }) => {
const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id);
const progress = participation
@@ -85,7 +110,7 @@ export class ChallengesService {
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
status: this.computeStatus(challenge.startAt, challenge.endAt),
status,
startAt: challenge.startAt,
endAt: challenge.endAt,
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
@@ -142,68 +167,24 @@ export class ChallengesService {
});
const rankingsRaw = await this.participantModel.findAll({
where: {
challengeId,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{ model: User, attributes: ['id', 'name', 'avatar'] }],
order: [
['progressValue', 'DESC'],
['updatedAt', 'ASC'],
],
limit: 10,
});
const rankingResult = await this.buildChallengeRankings(challenge, { page: 1, pageSize: 10 });
this.winstonLogger.info('get rankingsRaw end', {
this.winstonLogger.info('fetch rankings end', {
context: 'getChallengeDetail',
userId,
challengeId,
participantsCount,
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 progress = participation
? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt)
: undefined;
const rankings: RankingItemDto[] = rankingsRaw.map((item, 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(index),
todayReportedValue: todayReportsMap.get(item.userId) ?? 0,
todayTargetValue: challenge.targetValue,
};
});
const rankings: RankingItemDto[] = rankingResult.items;
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> {
const challenge = await this.challengeModel.findByPk(challengeId);
@@ -483,4 +507,66 @@ export class ChallengesService {
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,
};
}
}