import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; import { Challenge, ChallengeStatus } from './models/challenge.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model'; import { ChallengeProgressReport } from './models/challenge-progress-report.model'; 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'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger as WinstonLogger } from 'winston'; @Injectable() export class ChallengesService { constructor( @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger, @InjectModel(Challenge) private readonly challengeModel: typeof Challenge, @InjectModel(ChallengeParticipant) private readonly participantModel: typeof ChallengeParticipant, @InjectModel(ChallengeProgressReport) private readonly progressReportModel: typeof ChallengeProgressReport, ) { } async getChallengesForUser(userId?: string): Promise { const challenges = await this.challengeModel.findAll({ order: [['startAt', 'ASC']], }); if (!challenges.length) { return []; } 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), })) .filter(({ status }) => status !== ChallengeStatus.UPCOMING) .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: { challengeId: challengeIds, status: ChallengeParticipantStatus.ACTIVE, }, group: ['challenge_id'], raw: true, }); const participantsCountMap = new Map(); for (const item of participantCountsRaw as any[]) { const key = item.challengeId ?? item.challenge_id; if (key) { participantsCountMap.set(key, Number(item.count)); } } const participationMap = new Map(); if (userId) { const userParticipations = await this.participantModel.findAll({ where: { challengeId: challengeIds, userId, status: { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, }); for (const participation of userParticipations) { participationMap.set(participation.challengeId, participation); } } return challengesWithStatus.map(({ challenge, status }) => { const completionTarget = challenge.minimumCheckInDays const participation = participationMap.get(challenge.id); const progress = participation ? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt) : undefined; return { id: challenge.id, title: challenge.title, image: challenge.image, periodLabel: challenge.periodLabel, durationLabel: challenge.durationLabel, requirementLabel: challenge.requirementLabel, status, unit: challenge.progressUnit, startAt: challenge.startAt, endAt: challenge.endAt, participantsCount: participantsCountMap.get(challenge.id) ?? 0, rankingDescription: challenge.rankingDescription, highlightTitle: challenge.highlightTitle, highlightSubtitle: challenge.highlightSubtitle, ctaLabel: challenge.ctaLabel, minimumCheckInDays: completionTarget, progress, isJoined: Boolean(participation), type: challenge.type, }; }); } async getChallengeDetail(challengeId: string, userId?: string,): Promise { const challenge = await this.challengeModel.findByPk(challengeId); if (!challenge) { throw new NotFoundException('挑战不存在'); } this.winstonLogger.info('start get detail', { context: 'getChallengeDetail', userId, challengeId, }); const [participantsCount, participation] = await Promise.all([ this.participantModel.count({ where: { challengeId, status: ChallengeParticipantStatus.ACTIVE, }, }), userId ? this.participantModel.findOne({ where: { challengeId, userId, status: { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, }) : null, ]); this.winstonLogger.info('end get detail', { context: 'getChallengeDetail', userId, challengeId, participantsCount, participation, }); const rankingResult = await this.buildChallengeRankings(challenge, { page: 1, pageSize: 10 }); this.winstonLogger.info('fetch rankings end', { context: 'getChallengeDetail', userId, challengeId, participantsCount, participation, rankingsCount: rankingResult.items.length, }); const completionTarget = challenge.minimumCheckInDays const progress = participation ? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt) : undefined; const rankings: RankingItemDto[] = rankingResult.items; const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined; return { id: challenge.id, title: challenge.title, image: challenge.image, periodLabel: challenge.periodLabel, durationLabel: challenge.durationLabel, requirementLabel: challenge.requirementLabel, summary: challenge.summary, rankingDescription: challenge.rankingDescription, highlightTitle: challenge.highlightTitle, highlightSubtitle: challenge.highlightSubtitle, ctaLabel: challenge.ctaLabel, minimumCheckInDays: completionTarget, participantsCount, progress, rankings, userRank, unit: challenge.progressUnit, type: challenge.type, }; } 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); if (!challenge) { throw new NotFoundException('挑战不存在'); } const status = this.computeStatus(challenge.startAt, challenge.endAt); if (status === ChallengeStatus.EXPIRED) { throw new BadRequestException('挑战已过期,无法加入'); } const completionTarget = challenge.minimumCheckInDays if (completionTarget <= 0) { throw new BadRequestException('挑战配置存在问题,请联系管理员'); } const existing = await this.participantModel.findOne({ where: { challengeId, userId, }, }); if (existing && existing.status === ChallengeParticipantStatus.ACTIVE) { throw new ConflictException('已加入该挑战'); } if (existing && existing.status === ChallengeParticipantStatus.COMPLETED) { throw new ConflictException('该挑战已完成,如需重新参加请先退出'); } if (existing && existing.status === ChallengeParticipantStatus.LEFT) { existing.progressValue = 0; existing.targetValue = completionTarget; existing.status = ChallengeParticipantStatus.ACTIVE; existing.joinedAt = new Date(); existing.leftAt = null; existing.lastProgressAt = null; await existing.save(); return this.buildChallengeProgress(existing.progressValue, completionTarget, existing.lastProgressAt); } const participant = await this.participantModel.create({ challengeId, userId, progressValue: 0, targetValue: completionTarget, status: ChallengeParticipantStatus.ACTIVE, joinedAt: new Date(), }); return this.buildChallengeProgress(participant.progressValue, completionTarget, participant.lastProgressAt); } async leaveChallenge(userId: string, challengeId: string): Promise { const participant = await this.participantModel.findOne({ where: { challengeId, userId, status: { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, }); if (!participant) { throw new NotFoundException('尚未加入该挑战'); } participant.status = ChallengeParticipantStatus.LEFT; participant.leftAt = new Date(); await participant.save(); return true; } async reportProgress(userId: string, challengeId: string, dto: UpdateChallengeProgressDto): Promise { const challenge = await this.challengeModel.findByPk(challengeId); if (!challenge) { throw new NotFoundException('挑战不存在'); } const status = this.computeStatus(challenge.startAt, challenge.endAt); if (status === ChallengeStatus.UPCOMING) { throw new BadRequestException('挑战尚未开始,无法上报进度'); } if (status === ChallengeStatus.EXPIRED) { throw new BadRequestException('挑战已过期,无法上报进度'); } const participant = await this.participantModel.findOne({ where: { challengeId, userId, status: ChallengeParticipantStatus.ACTIVE, }, include: [{ model: Challenge, as: 'challenge', attributes: ['minimumCheckInDays', 'targetValue'], }], }); this.winstonLogger.info('start report progress', { context: 'reportProgress', userId, challengeId, participant, }); if (!participant) { throw new NotFoundException('请先加入挑战'); } // 如果要完成当日挑战,最低的上报数据 const reportCompletedValue = challenge.targetValue if (reportCompletedValue <= 0) { throw new BadRequestException('挑战配置存在问题,请联系管理员'); } if (dto.value === undefined || dto.value === null) { throw new BadRequestException('缺少上报的进度数据'); } if (dto.value < 0) { throw new BadRequestException('进度数据必须大于等于 0'); } const reportedValue = dto.value; const reportDate = dayjs().format('YYYY-MM-DD'); const now = new Date(); try { const [report, wasCreated] = await this.progressReportModel.findOrCreate({ where: { challengeId, userId, reportDate, }, defaults: { reportedValue, reportedAt: now, }, }); if (wasCreated) { if (report.reportedValue !== reportedValue) { await report.update({ reportedValue, reportedAt: now, }); } } else { if (report.reportedValue !== reportedValue) { report.reportedAt = now; report.reportedValue = reportedValue; await report.save(); } } if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) { participant.progressValue++ participant.lastProgressAt = now; } if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) { participant.status = ChallengeParticipantStatus.COMPLETED; } await participant.save(); this.winstonLogger.info('end report progress', { context: 'reportProgress', userId, challengeId, participant, }); return this.buildChallengeProgress(participant.progressValue, participant.targetValue, participant.lastProgressAt); } catch (error) { if (error instanceof UniqueConstraintError) { return this.buildChallengeProgress(participant.progressValue, participant.targetValue, participant.lastProgressAt); } throw error; } } private buildChallengeProgress( completed: number, target: number, lastProgressAt?: Date | string | null, unit = '天', ): ChallengeProgressDto { const remaining = Math.max(target - completed, 0); const checkedInToday = lastProgressAt ? dayjs(lastProgressAt).isSame(dayjs(), 'day') : false; return { completed, target, remaining, checkedInToday, }; } private computeStatus(startAt: number, endAt: number): ChallengeStatus { const now = dayjs(); const start = dayjs(startAt); const end = dayjs(endAt); if (now.isBefore(start, 'minute')) { return ChallengeStatus.UPCOMING; } if (now.isAfter(end, 'minute')) { return ChallengeStatus.EXPIRED; } return ChallengeStatus.ONGOING; } private resolveRankingBadge(index: number): string | undefined { if (index === 0) return 'gold'; if (index === 1) return 'silver'; if (index === 2) return 'bronze'; return undefined; } private async calculateUserRank(challengeId: string, participation: ChallengeParticipant): Promise { const { progressValue, updatedAt } = participation; const higherProgressCount = await this.participantModel.count({ where: { challengeId, status: ChallengeParticipantStatus.ACTIVE, [Op.or]: [ { progressValue: { [Op.gt]: progressValue } }, { progressValue, updatedAt: { [Op.lt]: updatedAt }, }, ], }, }); 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, }; } }