feat(challenges): 新增分页排行榜接口并重构排行逻辑
- 新增 GET /challenges/:id/rankings 接口,支持分页查询排行榜 - 抽离 buildChallengeRankings 方法,统一排行榜数据构建逻辑 - 新增 ChallengeRankingListDto 与 GetChallengeRankingQueryDto 用于接口数据校验 - 优化挑战列表排序逻辑,按状态优先级与时间排序 - 修复排行榜索引计算错误,确保分页场景下排名正确
This commit is contained in:
@@ -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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
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 { ChallengeListItemDto } from './dto/challenge-list.dto';
|
||||
import { ChallengeProgressDto } from './dto/challenge-progress.dto';
|
||||
import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto';
|
||||
|
||||
@Controller('challenges')
|
||||
@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')
|
||||
async joinChallenge(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
23
src/challenges/dto/challenge-ranking.dto.ts
Normal file
23
src/challenges/dto/challenge-ranking.dto.ts
Normal 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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user