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 { // 获取系统挑战 + 用户已加入的自定义挑战 + 用户创建的自定义挑战 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.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(); 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); } } // 🎖️ 查询 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 { 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 { 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 { 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 { // 先检查挑战是否存在且未归档 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 { 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 { 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, }; } // ==================== 自定义挑战功能 ==================== /** * 生成唯一的分享码 */ private async generateUniqueShareCode(): Promise { 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 { 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 { 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 { // 验证时间 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 { 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 { 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 { 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 { 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 { 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 { 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, }; } }