import { Injectable, NotFoundException, BadRequestException, ConflictException } 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 { fn, col, Op, UniqueConstraintError } from 'sequelize'; import * as dayjs from 'dayjs'; import { User } from '../users/models/user.model'; @Injectable() export class ChallengesService { constructor( @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 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 userParticipations = await this.participantModel.findAll({ where: { challengeId: challengeIds, userId, status: { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, }); const participationMap = new Map(); for (const participation of userParticipations) { participationMap.set(participation.challengeId, participation); } return challenges.map((challenge) => { const participation = participationMap.get(challenge.id); const progress = participation ? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit) : undefined; return { id: challenge.id, title: challenge.title, image: challenge.image, periodLabel: challenge.periodLabel, durationLabel: challenge.durationLabel, requirementLabel: challenge.requirementLabel, status: this.computeStatus(challenge.startAt, challenge.endAt), startAt: challenge.startAt, endAt: challenge.endAt, participantsCount: participantsCountMap.get(challenge.id) ?? 0, rankingDescription: challenge.rankingDescription, highlightTitle: challenge.highlightTitle, highlightSubtitle: challenge.highlightSubtitle, ctaLabel: challenge.ctaLabel, progress, isJoined: Boolean(participation), }; }); } async getChallengeDetail(userId: string, challengeId: string): Promise { const challenge = await this.challengeModel.findByPk(challengeId); if (!challenge) { throw new NotFoundException('挑战不存在'); } const [participantsCount, participation] = await Promise.all([ this.participantModel.count({ where: { challengeId, status: ChallengeParticipantStatus.ACTIVE, }, }), this.participantModel.findOne({ where: { challengeId, userId, status: { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, }), ]); 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 progress = participation ? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit) : undefined; const rankings: RankingItemDto[] = rankingsRaw.map((item, index) => ({ id: item.user?.id ?? item.userId, name: item.user?.name ?? '未知用户', avatar: item.user?.avatar ?? null, metric: `${item.progressValue}/${item.targetValue}${challenge.progressUnit}`, badge: this.resolveRankingBadge(index), })); 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, participantsCount, progress, rankings, userRank, }; } 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 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 = challenge.targetValue; existing.status = ChallengeParticipantStatus.ACTIVE; existing.joinedAt = new Date(); existing.leftAt = null; existing.lastProgressAt = null; await existing.save(); return this.buildChallengeProgress(existing.progressValue, existing.targetValue, challenge.progressUnit); } const participant = await this.participantModel.create({ challengeId, userId, progressValue: 0, targetValue: challenge.targetValue, status: ChallengeParticipantStatus.ACTIVE, joinedAt: new Date(), }); return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit); } 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, }, }); if (!participant) { throw new NotFoundException('请先加入挑战'); } const increment = dto.increment ?? 1; if (increment < 1) { throw new BadRequestException('进度增量必须大于 0'); } const reportDate = dayjs().format('YYYY-MM-DD'); const now = new Date(); const remainingCapacity = Math.max(participant.targetValue - participant.progressValue, 0); const effectiveIncrement = Math.min(increment, remainingCapacity); let created = false; try { const [report, wasCreated] = await this.progressReportModel.findOrCreate({ where: { challengeId, userId, reportDate, }, defaults: { incrementValue: effectiveIncrement, reportedAt: now, }, }); created = wasCreated; if (wasCreated) { await report.update({ incrementValue: effectiveIncrement, reportedAt: now, }); } } catch (error) { if (error instanceof UniqueConstraintError) { return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit); } throw error; } if (!created) { return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit); } if (effectiveIncrement > 0) { const newProgress = Math.min(participant.progressValue + effectiveIncrement, participant.targetValue); participant.progressValue = newProgress; participant.lastProgressAt = now; if (participant.progressValue >= participant.targetValue) { participant.status = ChallengeParticipantStatus.COMPLETED; } await participant.save(); } return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit); } private buildChallengeProgress(completed: number, target: number, unit: string): ChallengeProgressDto { const remaining = Math.max(target - completed, 0); return { completed, target, remaining, badge: completed >= target ? '已完成' : `已坚持 ${completed}${unit}`, subtitle: remaining > 0 ? `还差 ${remaining}${unit}` : undefined, }; } private computeStatus(startAt: Date, endAt: Date): 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; } }