feat(challenges): 新增挑战功能模块及完整接口实现
- 新增挑战列表、详情、加入/退出、进度上报等 REST 接口 - 定义 Challenge / ChallengeParticipant 数据模型与状态枚举 - 提供排行榜查询与用户排名计算 - 包含接口文档与数据库初始化脚本
This commit is contained in:
334
src/challenges/challenges.service.ts
Normal file
334
src/challenges/challenges.service.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
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 { 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 } 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,
|
||||
) { }
|
||||
|
||||
async getChallengesForUser(userId: string): Promise<ChallengeListItemDto[]> {
|
||||
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<string, number>();
|
||||
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<string, ChallengeParticipant>();
|
||||
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<ChallengeDetailDto> {
|
||||
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<ChallengeProgressDto> {
|
||||
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<boolean> {
|
||||
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<ChallengeProgressDto> {
|
||||
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 newProgress = participant.progressValue + increment;
|
||||
participant.progressValue = Math.min(newProgress, participant.targetValue);
|
||||
participant.lastProgressAt = new Date();
|
||||
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user