Files
plates-server/src/challenges/challenges.service.ts
richarjiang 999fc7f793 feat(challenges): 支持公开访问挑战列表与详情接口
- 在 GET /challenges、GET /challenges/:id、GET /challenges/:id/rankings 添加 @Public() 装饰器,允许未登录用户访问
- 将 userId 改为可选参数,未登录时仍可返回基础数据
- 列表接口过滤掉 UPCOMING 状态挑战,仅展示进行中/已结束
- 返回 DTO 新增 unit 字段,用于前端展示进度单位
- 鉴权守卫优化:公开接口若携带 token 仍尝试解析并注入 user,方便后续业务逻辑
2025-09-30 16:43:46 +08:00

581 lines
17 KiB
TypeScript

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<ChallengeListItemDto[]> {
const challenges = await this.challengeModel.findAll({
order: [['startAt', 'ASC']],
});
if (!challenges.length) {
return [];
}
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),
}))
.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<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 participationMap = new Map<string, ChallengeParticipant>();
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<ChallengeDetailDto> {
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<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> {
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<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,
},
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<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;
}
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,
};
}
}