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 { 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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