1260 lines
39 KiB
TypeScript
1260 lines
39 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject, ForbiddenException } from '@nestjs/common';
|
|
import { InjectModel } from '@nestjs/sequelize';
|
|
import { Challenge, ChallengeStatus, ChallengeType, ChallengeSource, ChallengeState } 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 { CreateCustomChallengeDto } from './dto/create-custom-challenge.dto';
|
|
import { UpdateCustomChallengeDto } from './dto/update-custom-challenge.dto';
|
|
import { CustomChallengeResponseDto } from './dto/custom-challenge-response.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';
|
|
import { BadgeService } from '../users/services/badge.service';
|
|
import { BadgeSource } from '../users/models/user-badge.model';
|
|
import { BadgeConfig } from '../users/models/badge-config.model';
|
|
|
|
@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,
|
|
@InjectModel(BadgeConfig)
|
|
private readonly badgeConfigModel: typeof BadgeConfig,
|
|
private readonly badgeService: BadgeService,
|
|
) { }
|
|
|
|
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
|
|
// 获取系统挑战 + 用户已加入的自定义挑战 + 用户创建的自定义挑战
|
|
const whereConditions: any[] = [
|
|
{ source: ChallengeSource.SYSTEM, challengeState: ChallengeState.ACTIVE },
|
|
];
|
|
|
|
if (userId) {
|
|
// 获取用户加入的自定义挑战 ID
|
|
const joinedChallengeIds = await this.getJoinedCustomChallengeIds(userId);
|
|
|
|
whereConditions.push(
|
|
{ creatorId: userId, source: ChallengeSource.CUSTOM, challengeState: { [Op.ne]: ChallengeState.ARCHIVED } }, // 我创建的
|
|
{
|
|
id: { [Op.in]: joinedChallengeIds },
|
|
source: ChallengeSource.CUSTOM,
|
|
challengeState: ChallengeState.ACTIVE
|
|
} // 我加入的
|
|
);
|
|
}
|
|
|
|
const challenges = await this.challengeModel.findAll({
|
|
where: {
|
|
[Op.or]: whereConditions,
|
|
challengeState: { [Op.ne]: ChallengeState.ARCHIVED }, // 过滤掉已归档的挑战
|
|
},
|
|
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.ONGOING)
|
|
.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);
|
|
}
|
|
}
|
|
|
|
// 🎖️ 查询 sleepChallengeMonth 勋章信息(仅在有睡眠挑战时查询)
|
|
const hasSleepChallenge = challengesWithStatus.some(({ challenge }) => challenge.type === ChallengeType.SLEEP);
|
|
let sleepBadge: ChallengeListItemDto['badge'] = undefined;
|
|
if (hasSleepChallenge) {
|
|
const badgeConfig = await this.badgeConfigModel.findOne({
|
|
where: { code: 'sleepChallengeMonth', isActive: true },
|
|
});
|
|
|
|
if (badgeConfig) {
|
|
sleepBadge = {
|
|
code: badgeConfig.code,
|
|
name: badgeConfig.name,
|
|
description: badgeConfig.description,
|
|
imageUrl: badgeConfig.imageUrl,
|
|
category: badgeConfig.category,
|
|
};
|
|
}
|
|
}
|
|
|
|
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: new Date(challenge.startAt).getTime(),
|
|
endAt: new Date(challenge.endAt).getTime(),
|
|
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,
|
|
badge: challenge.type === ChallengeType.SLEEP ? sleepBadge : undefined,
|
|
source: challenge.source,
|
|
shareCode: challenge.shareCode,
|
|
isCreator: userId ? challenge.creatorId === userId : false,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getChallengeDetail(challengeId: string, userId?: string,): Promise<ChallengeDetailDto> {
|
|
const challenge = await this.challengeModel.findByPk(challengeId);
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
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;
|
|
|
|
// 🎖️ 如果是睡眠挑战,获取 sleepChallengeMonth 勋章信息
|
|
let badge: ChallengeDetailDto['badge'] = undefined;
|
|
if (challenge.type === ChallengeType.SLEEP) {
|
|
const badgeConfig = await this.badgeConfigModel.findOne({
|
|
where: { code: 'sleepChallengeMonth', isActive: true },
|
|
});
|
|
|
|
if (badgeConfig) {
|
|
badge = {
|
|
code: badgeConfig.code,
|
|
name: badgeConfig.name,
|
|
description: badgeConfig.description,
|
|
imageUrl: badgeConfig.imageUrl,
|
|
category: badgeConfig.category,
|
|
};
|
|
}
|
|
}
|
|
|
|
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,
|
|
badge,
|
|
creatorId: challenge.creatorId,
|
|
shareCode: challenge.shareCode,
|
|
source: challenge.source,
|
|
isCreator: userId ? challenge.creatorId === userId : false,
|
|
};
|
|
}
|
|
|
|
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('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
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('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
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 challenge = await this.challengeModel.findByPk(challengeId);
|
|
if (!challenge) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
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('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
this.winstonLogger.info('progress report updated', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
reportDate,
|
|
reportedValue,
|
|
})
|
|
|
|
// 🎖️ 检查是否为睡眠挑战且完成了第一次打卡,授予 goodSleep 勋章
|
|
if (challenge.type === ChallengeType.SLEEP) {
|
|
this.winstonLogger.info('检查是否为睡眠挑战且完成了第一次打卡,授予 goodSleep 勋章', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
badgeCode: 'goodSleep',
|
|
})
|
|
try {
|
|
await this.badgeService.awardBadge(userId, 'goodSleep', {
|
|
source: BadgeSource.CHALLENGE,
|
|
sourceId: challengeId,
|
|
metadata: {
|
|
challengeName: challenge.title,
|
|
challengeType: challenge.type,
|
|
},
|
|
});
|
|
this.winstonLogger.info('授予睡眠挑战勋章成功', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
badgeCode: 'goodSleep',
|
|
});
|
|
} catch (error) {
|
|
// 勋章授予失败不应影响主流程,仅记录日志
|
|
this.winstonLogger.error('授予睡眠挑战勋章失败', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
error: error instanceof Error ? error.message : '未知错误',
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 🎖️ 完成睡眠挑战时,授予 sleepChallengeMonth 勋章
|
|
if (challenge.type === ChallengeType.SLEEP) {
|
|
try {
|
|
await this.badgeService.awardBadge(userId, 'sleepChallengeMonth', {
|
|
source: BadgeSource.CHALLENGE,
|
|
sourceId: challengeId,
|
|
metadata: {
|
|
challengeName: challenge.title,
|
|
challengeType: challenge.type,
|
|
completedDays: participant.progressValue,
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
this.winstonLogger.info('授予睡眠挑战完成勋章成功', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
badgeCode: 'sleepChallengeMonth',
|
|
completedDays: participant.progressValue,
|
|
});
|
|
} catch (error) {
|
|
// 勋章授予失败不应影响主流程,仅记录日志
|
|
this.winstonLogger.error('授予睡眠挑战完成勋章失败', {
|
|
context: 'reportProgress',
|
|
userId,
|
|
challengeId,
|
|
badgeCode: 'sleepChallengeMonth',
|
|
error: error instanceof Error ? error.message : '未知错误',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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: Date | number, endAt: Date | 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,
|
|
};
|
|
}
|
|
|
|
// ==================== 自定义挑战功能 ====================
|
|
|
|
/**
|
|
* 生成唯一的分享码
|
|
*/
|
|
private async generateUniqueShareCode(): Promise<string> {
|
|
const chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // 避免混淆字符
|
|
let attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
while (attempts < maxAttempts) {
|
|
let code = '';
|
|
for (let i = 0; i < 6; i++) {
|
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
|
|
const existing = await this.challengeModel.findOne({
|
|
where: { shareCode: code },
|
|
});
|
|
|
|
if (!existing) {
|
|
return code;
|
|
}
|
|
attempts++;
|
|
}
|
|
|
|
// 如果 10 次都冲突,使用更长的码
|
|
let code = '';
|
|
for (let i = 0; i < 8; i++) {
|
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return code;
|
|
}
|
|
|
|
/**
|
|
* 获取用户当前参与的活跃挑战数量(正在进行中且未过期)
|
|
* @param userId 用户ID
|
|
* @returns 活跃挑战数量
|
|
*/
|
|
async getActiveParticipatingChallengeCount(userId: string): Promise<number> {
|
|
const now = new Date();
|
|
|
|
// 查询用户参与的所有活跃状态的挑战
|
|
const participants = await this.participantModel.findAll({
|
|
where: {
|
|
userId,
|
|
status: {
|
|
[Op.in]: [ChallengeParticipantStatus.ACTIVE, ChallengeParticipantStatus.COMPLETED],
|
|
},
|
|
},
|
|
include: [{
|
|
model: Challenge,
|
|
as: 'challenge',
|
|
where: {
|
|
challengeState: ChallengeState.ACTIVE,
|
|
startAt: { [Op.lte]: now },
|
|
endAt: { [Op.gte]: now },
|
|
},
|
|
required: true,
|
|
}],
|
|
});
|
|
|
|
return participants.length;
|
|
}
|
|
|
|
/**
|
|
* 获取用户加入的自定义挑战 ID 列表
|
|
*/
|
|
private async getJoinedCustomChallengeIds(userId: string): Promise<string[]> {
|
|
const participants = await this.participantModel.findAll({
|
|
where: {
|
|
userId,
|
|
status: {
|
|
[Op.ne]: ChallengeParticipantStatus.LEFT,
|
|
},
|
|
},
|
|
attributes: ['challengeId'],
|
|
raw: true,
|
|
});
|
|
|
|
return participants.map(p => p.challengeId);
|
|
}
|
|
|
|
/**
|
|
* 检查挑战是否可以加入
|
|
*/
|
|
private async canJoinChallenge(challenge: Challenge): Promise<{ canJoin: boolean; reason?: string }> {
|
|
// 检查挑战状态
|
|
if (challenge.challengeState !== ChallengeState.ACTIVE) {
|
|
return { canJoin: false, reason: '挑战未激活' };
|
|
}
|
|
|
|
// 检查时间
|
|
const status = this.computeStatus(challenge.startAt, challenge.endAt);
|
|
if (status === ChallengeStatus.EXPIRED) {
|
|
return { canJoin: false, reason: '挑战已过期' };
|
|
}
|
|
|
|
// 检查人数限制
|
|
if (challenge.maxParticipants) {
|
|
const count = await this.participantModel.count({
|
|
where: {
|
|
challengeId: challenge.id,
|
|
status: ChallengeParticipantStatus.ACTIVE,
|
|
},
|
|
});
|
|
|
|
if (count >= challenge.maxParticipants) {
|
|
return { canJoin: false, reason: '挑战人数已满' };
|
|
}
|
|
}
|
|
|
|
return { canJoin: true };
|
|
}
|
|
|
|
/**
|
|
* 创建自定义挑战
|
|
*/
|
|
async createCustomChallenge(userId: string, dto: CreateCustomChallengeDto): Promise<CustomChallengeResponseDto> {
|
|
// 验证时间
|
|
if (dto.startAt >= dto.endAt) {
|
|
throw new BadRequestException('结束时间必须晚于开始时间');
|
|
}
|
|
|
|
// 将毫秒时间戳转换为 Date 对象,以匹配数据库 DATETIME 类型
|
|
const startAtDate = new Date(dto.startAt);
|
|
const endAtDate = new Date(dto.endAt);
|
|
|
|
// 验证日期是否有效
|
|
if (isNaN(startAtDate.getTime()) || isNaN(endAtDate.getTime())) {
|
|
throw new BadRequestException('无效的时间戳');
|
|
}
|
|
|
|
// 获取用户信息,检查会员状态
|
|
const user = await User.findByPk(userId);
|
|
if (!user) {
|
|
throw new NotFoundException('用户不存在');
|
|
}
|
|
|
|
// 检查非会员用户已创建的未归档挑战数量
|
|
if (!user.isVip) {
|
|
const existingChallengeCount = await this.challengeModel.count({
|
|
where: {
|
|
creatorId: userId,
|
|
source: ChallengeSource.CUSTOM,
|
|
challengeState: {
|
|
[Op.ne]: ChallengeState.ARCHIVED, // 不包含已归档的挑战
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingChallengeCount >= 1) {
|
|
throw new BadRequestException('非会员用户只能创建一个挑战,您可以先归档现有挑战或升级会员');
|
|
}
|
|
}
|
|
|
|
// 检查创建频率限制(每天最多创建 5 个)
|
|
const recentCount = await this.challengeModel.count({
|
|
where: {
|
|
creatorId: userId,
|
|
createdAt: {
|
|
[Op.gte]: dayjs().subtract(24, 'hour').toDate(),
|
|
},
|
|
},
|
|
});
|
|
|
|
if (recentCount >= 5) {
|
|
throw new BadRequestException('每天最多创建 5 个挑战,请明天再试');
|
|
}
|
|
|
|
// 生成分享码
|
|
const shareCode = await this.generateUniqueShareCode();
|
|
|
|
|
|
// 创建挑战
|
|
const challenge = await this.challengeModel.create({
|
|
title: dto.title,
|
|
type: dto.type,
|
|
image: dto.image || null,
|
|
startAt: startAtDate,
|
|
endAt: endAtDate,
|
|
periodLabel: dto.periodLabel || null,
|
|
durationLabel: dto.durationLabel,
|
|
requirementLabel: dto.requirementLabel,
|
|
summary: dto.summary || null,
|
|
targetValue: dto.targetValue,
|
|
progressUnit: dto.progressUnit || '天',
|
|
minimumCheckInDays: dto.minimumCheckInDays,
|
|
rankingDescription: dto.rankingDescription || '连续打卡榜',
|
|
highlightTitle: dto.title,
|
|
highlightSubtitle: dto.summary || '养成好习惯',
|
|
ctaLabel: '立即加入',
|
|
source: ChallengeSource.CUSTOM,
|
|
creatorId: userId,
|
|
shareCode,
|
|
isPublic: dto.isPublic !== undefined ? dto.isPublic : true,
|
|
maxParticipants: dto.maxParticipants || null,
|
|
challengeState: ChallengeState.ACTIVE,
|
|
});
|
|
|
|
// 创建者自动加入挑战
|
|
await this.joinChallenge(userId, challenge.id);
|
|
|
|
this.winstonLogger.info('创建自定义挑战成功,创建者已自动加入', {
|
|
context: 'createCustomChallenge',
|
|
userId,
|
|
challengeId: challenge.id,
|
|
shareCode,
|
|
isVip: user.isVip,
|
|
existingChallengeCount: user.isVip ? null : 1,
|
|
});
|
|
|
|
return this.buildCustomChallengeResponse(challenge, userId);
|
|
}
|
|
|
|
/**
|
|
* 通过分享码加入挑战
|
|
*/
|
|
async joinByShareCode(userId: string, shareCode: string): Promise<ChallengeProgressDto> {
|
|
const challenge = await this.challengeModel.findOne({
|
|
where: {
|
|
shareCode: shareCode.toUpperCase(),
|
|
challengeState: ChallengeState.ACTIVE,
|
|
},
|
|
});
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('分享码无效或挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
throw new NotFoundException('分享码无效或挑战不存在');
|
|
}
|
|
|
|
// 检查是否可以加入
|
|
const { canJoin, reason } = await this.canJoinChallenge(challenge);
|
|
if (!canJoin) {
|
|
throw new BadRequestException(reason || '无法加入挑战');
|
|
}
|
|
|
|
// 使用现有的加入逻辑
|
|
return this.joinChallenge(userId, challenge.id);
|
|
}
|
|
|
|
/**
|
|
* 获取分享码对应的挑战信息(公开接口)
|
|
*/
|
|
async getChallengeByShareCode(shareCode: string, userId?: string): Promise<ChallengeDetailDto> {
|
|
const challenge = await this.challengeModel.findOne({
|
|
where: {
|
|
shareCode: shareCode.toUpperCase(),
|
|
challengeState: ChallengeState.ACTIVE,
|
|
},
|
|
});
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('分享码无效或挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
throw new NotFoundException('分享码无效或挑战不存在');
|
|
}
|
|
|
|
return this.getChallengeDetail(challenge.id, userId);
|
|
}
|
|
|
|
/**
|
|
* 更新自定义挑战
|
|
*/
|
|
async updateCustomChallenge(
|
|
userId: string,
|
|
challengeId: string,
|
|
dto: UpdateCustomChallengeDto,
|
|
): Promise<CustomChallengeResponseDto> {
|
|
const challenge = await this.challengeModel.findByPk(challengeId);
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.source !== ChallengeSource.CUSTOM) {
|
|
throw new BadRequestException('只能编辑自定义挑战');
|
|
}
|
|
|
|
if (challenge.creatorId !== userId) {
|
|
throw new ForbiddenException('只有创建者才能编辑挑战');
|
|
}
|
|
|
|
// 如果挑战已开始,限制可编辑字段
|
|
const status = this.computeStatus(challenge.startAt, challenge.endAt);
|
|
if (status !== ChallengeStatus.UPCOMING) {
|
|
// 挑战已开始,只允许编辑部分字段
|
|
const allowedFields: (keyof UpdateCustomChallengeDto)[] = [
|
|
'summary',
|
|
'isPublic',
|
|
'highlightTitle',
|
|
'highlightSubtitle',
|
|
'ctaLabel',
|
|
'title',
|
|
'summary',
|
|
'maxParticipants',
|
|
'highlightSubtitle',
|
|
'highlightTitle',
|
|
'image'
|
|
];
|
|
|
|
const restrictedFields = Object.keys(dto).filter(
|
|
key => !allowedFields.includes(key as keyof UpdateCustomChallengeDto)
|
|
);
|
|
|
|
if (restrictedFields.length > 0) {
|
|
const allowedFieldsDescription = '概要(summary)、公开性(isPublic)、展示文案(highlightTitle、highlightSubtitle、ctaLabel)、标题(title)、图片(image)和最大参与人数(maxParticipants)';
|
|
throw new BadRequestException(`挑战已开始,只能编辑部分字段。可编辑的字段包括:${allowedFieldsDescription}。您尝试编辑的字段:${restrictedFields.join('、')} 不在允许范围内。`);
|
|
}
|
|
}
|
|
|
|
// 更新挑战
|
|
await challenge.update(dto);
|
|
|
|
this.winstonLogger.info('更新自定义挑战成功', {
|
|
context: 'updateCustomChallenge',
|
|
userId,
|
|
challengeId,
|
|
updates: Object.keys(dto),
|
|
});
|
|
|
|
return this.buildCustomChallengeResponse(challenge, userId);
|
|
}
|
|
|
|
/**
|
|
* 归档自定义挑战
|
|
*/
|
|
async archiveCustomChallenge(userId: string, challengeId: string): Promise<boolean> {
|
|
const challenge = await this.challengeModel.findByPk(challengeId);
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.source !== ChallengeSource.CUSTOM) {
|
|
throw new BadRequestException('只能归档自定义挑战');
|
|
}
|
|
|
|
if (challenge.creatorId !== userId) {
|
|
throw new ForbiddenException('只有创建者才能归档挑战');
|
|
}
|
|
|
|
await challenge.update({ challengeState: ChallengeState.ARCHIVED });
|
|
|
|
this.winstonLogger.info('归档自定义挑战成功', {
|
|
context: 'archiveCustomChallenge',
|
|
userId,
|
|
challengeId,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 重新生成分享码
|
|
*/
|
|
async regenerateShareCode(userId: string, challengeId: string): Promise<string> {
|
|
const challenge = await this.challengeModel.findByPk(challengeId);
|
|
|
|
if (!challenge) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
|
throw new NotFoundException('挑战不存在');
|
|
}
|
|
|
|
if (challenge.source !== ChallengeSource.CUSTOM) {
|
|
throw new BadRequestException('只能为自定义挑战重新生成分享码');
|
|
}
|
|
|
|
if (challenge.creatorId !== userId) {
|
|
throw new ForbiddenException('只有创建者才能重新生成分享码');
|
|
}
|
|
|
|
const newShareCode = await this.generateUniqueShareCode();
|
|
await challenge.update({ shareCode: newShareCode });
|
|
|
|
this.winstonLogger.info('重新生成分享码成功', {
|
|
context: 'regenerateShareCode',
|
|
userId,
|
|
challengeId,
|
|
oldShareCode: challenge.shareCode,
|
|
newShareCode,
|
|
});
|
|
|
|
return newShareCode;
|
|
}
|
|
|
|
/**
|
|
* 获取我创建的挑战列表
|
|
*/
|
|
async getMyCreatedChallenges(
|
|
userId: string,
|
|
params: { page?: number; pageSize?: number; state?: ChallengeState } = {},
|
|
): Promise<{ items: CustomChallengeResponseDto[]; total: number; page: number; pageSize: number }> {
|
|
const page = params.page && params.page > 0 ? params.page : 1;
|
|
const pageSize = params.pageSize && params.pageSize > 0 ? Math.min(params.pageSize, 100) : 20;
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
const where: any = {
|
|
creatorId: userId,
|
|
source: ChallengeSource.CUSTOM,
|
|
};
|
|
|
|
if (params.state) {
|
|
where.challengeState = params.state;
|
|
}
|
|
|
|
const { rows, count } = await this.challengeModel.findAndCountAll({
|
|
where,
|
|
order: [['createdAt', 'DESC']],
|
|
limit: pageSize,
|
|
offset,
|
|
});
|
|
|
|
const items = await Promise.all(
|
|
rows.map(challenge => this.buildCustomChallengeResponse(challenge, userId))
|
|
);
|
|
|
|
return {
|
|
items,
|
|
total: count,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 构建自定义挑战响应
|
|
*/
|
|
private async buildCustomChallengeResponse(
|
|
challenge: Challenge,
|
|
userId: string,
|
|
): Promise<CustomChallengeResponseDto> {
|
|
const [participantsCount, participation] = await Promise.all([
|
|
this.participantModel.count({
|
|
where: {
|
|
challengeId: challenge.id,
|
|
status: ChallengeParticipantStatus.ACTIVE,
|
|
},
|
|
}),
|
|
this.participantModel.findOne({
|
|
where: {
|
|
challengeId: challenge.id,
|
|
userId,
|
|
status: {
|
|
[Op.ne]: ChallengeParticipantStatus.LEFT,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const progress = participation
|
|
? this.buildChallengeProgress(
|
|
participation.progressValue,
|
|
challenge.minimumCheckInDays,
|
|
participation.lastProgressAt,
|
|
)
|
|
: undefined;
|
|
|
|
return {
|
|
id: challenge.id,
|
|
title: challenge.title,
|
|
type: challenge.type,
|
|
source: challenge.source as ChallengeSource,
|
|
creatorId: challenge.creatorId,
|
|
shareCode: challenge.shareCode,
|
|
image: challenge.image,
|
|
startAt: new Date(challenge.startAt).getTime(),
|
|
endAt: new Date(challenge.endAt).getTime(),
|
|
periodLabel: challenge.periodLabel,
|
|
durationLabel: challenge.durationLabel,
|
|
requirementLabel: challenge.requirementLabel || '',
|
|
summary: challenge.summary,
|
|
targetValue: challenge.targetValue,
|
|
progressUnit: challenge.progressUnit,
|
|
minimumCheckInDays: challenge.minimumCheckInDays,
|
|
rankingDescription: challenge.rankingDescription,
|
|
highlightTitle: challenge.highlightTitle,
|
|
highlightSubtitle: challenge.highlightSubtitle,
|
|
ctaLabel: challenge.ctaLabel,
|
|
isPublic: challenge.isPublic,
|
|
maxParticipants: challenge.maxParticipants,
|
|
challengeState: challenge.challengeState as ChallengeState,
|
|
participantsCount,
|
|
progress,
|
|
isJoined: Boolean(participation),
|
|
isCreator: challenge.creatorId === userId,
|
|
createdAt: challenge.createdAt,
|
|
updatedAt: challenge.updatedAt,
|
|
};
|
|
}
|
|
}
|