diff --git a/src/challenges/challenges.controller.ts b/src/challenges/challenges.controller.ts index 01ede9d..a0a5295 100644 --- a/src/challenges/challenges.controller.ts +++ b/src/challenges/challenges.controller.ts @@ -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> { + 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, diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index 122e133..70442eb 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -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.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(); - - 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 { + 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 { 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(); + + 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, + }; + } } diff --git a/src/challenges/dto/challenge-ranking.dto.ts b/src/challenges/dto/challenge-ranking.dto.ts new file mode 100644 index 0000000..f6c6208 --- /dev/null +++ b/src/challenges/dto/challenge-ranking.dto.ts @@ -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[]; +} +