import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron } from '@nestjs/schedule'; import { InjectModel } from '@nestjs/sequelize'; import { Op } from 'sequelize'; import { PushType } from 'apns2'; import { PushNotificationsService } from './push-notifications.service'; import { PushTokenService } from './push-token.service'; import { UserPushToken } from './models/user-push-token.model'; import { PushReminderHistory, ReminderType } from './models/push-reminder-history.model'; import { ChallengesService } from '../challenges/challenges.service'; import { Challenge } from '../challenges/models/challenge.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from '../challenges/models/challenge-participant.model'; import { getEncouragementTemplate, getInvitationTemplate, getGeneralInvitationTemplate } from './templates/challenge-templates'; import * as dayjs from 'dayjs'; @Injectable() export class ChallengeReminderService { private readonly logger = new Logger(ChallengeReminderService.name); constructor( @InjectModel(UserPushToken) private readonly pushTokenModel: typeof UserPushToken, @InjectModel(PushReminderHistory) private readonly reminderHistoryModel: typeof PushReminderHistory, @InjectModel(ChallengeParticipant) private readonly participantModel: typeof ChallengeParticipant, private readonly pushNotificationsService: PushNotificationsService, private readonly pushTokenService: PushTokenService, private readonly challengesService: ChallengesService, private readonly configService: ConfigService, ) {} /** * 每晚8点执行的挑战提醒定时任务 */ @Cron('0 20 * * *', { name: 'challengeReminder', timeZone: 'Asia/Shanghai', }) async handleChallengeReminder(): Promise { this.logger.log('Starting daily challenge reminder task...'); try { // 检查是否为主进程(NODE_APP_INSTANCE 为 0) const nodeAppInstance = this.configService.get('NODE_APP_INSTANCE', 0); if (Number(nodeAppInstance) !== 0) { this.logger.log(`Not the primary process (instance: ${nodeAppInstance}). Skipping challenge reminder...`); return; } this.logger.log('Primary process detected. Running challenge reminder...'); // 1. 获取所有活跃的推送令牌 const activeTokens = await this.getActivePushTokens(); if (activeTokens.length === 0) { this.logger.log('No active push tokens found'); return; } this.logger.log(`Found ${activeTokens.length} active push tokens`); // 2. 获取正在进行的挑战 const ongoingChallenges = await this.getOngoingChallenges(); if (ongoingChallenges.length === 0) { this.logger.log('No ongoing challenges found'); return; } this.logger.log(`Found ${ongoingChallenges.length} ongoing challenges`); // 3. 获取参与挑战的活跃用户 const activeParticipants = await this.getActiveParticipants(ongoingChallenges); const participantUserIds = new Set(activeParticipants.map(p => p.userId)); this.logger.log(`Found ${activeParticipants.length} active participants`); // 4. 按用户类型分组处理 await this.processTokensByUserType(activeTokens, participantUserIds, ongoingChallenges); } catch (error) { this.logger.error(`Error during challenge reminder task: ${error.message}`, error); } } /** * 获取所有活跃的推送令牌 */ private async getActivePushTokens(): Promise { return await this.pushTokenModel.findAll({ where: { isActive: true, }, }); } /** * 获取正在进行中的挑战 */ private async getOngoingChallenges(): Promise { const challenges = await this.challengesService['challengeModel'].findAll({ order: [['startAt', 'ASC']], }); // 过滤出真正进行中的挑战 return challenges.filter(challenge => { const start = dayjs(challenge.startAt); const end = dayjs(challenge.endAt); const current = dayjs(); return current.isAfter(start, 'minute') && current.isBefore(end, 'minute'); }); } /** * 获取参与挑战的活跃用户 */ private async getActiveParticipants(challenges: Challenge[]): Promise { const challengeIds = challenges.map(challenge => challenge.id); return await this.participantModel.findAll({ where: { challengeId: { [Op.in]: challengeIds, }, status: ChallengeParticipantStatus.ACTIVE, }, }); } /** * 按用户类型分组处理推送令牌 */ private async processTokensByUserType( tokens: UserPushToken[], participantUserIds: Set, challenges: Challenge[] ): Promise { // 分组:已参与挑战的用户、未参与挑战但有userId的用户、没有userId的用户 const participatingTokens: UserPushToken[] = []; const nonParticipatingTokens: UserPushToken[] = []; const anonymousTokens: UserPushToken[] = []; for (const token of tokens) { if (!token.userId) { anonymousTokens.push(token); } else if (participantUserIds.has(token.userId)) { participatingTokens.push(token); } else { nonParticipatingTokens.push(token); } } this.logger.log(`Token groups - Participating: ${participatingTokens.length}, Non-participating: ${nonParticipatingTokens.length}, Anonymous: ${anonymousTokens.length}`); // 处理已参与挑战的用户 - 发送鼓励文案(每天) await this.sendEncouragementReminders(participatingTokens); // 处理未参与挑战但有userId的用户 - 发送邀请文案(隔天) await this.sendInvitationReminders(nonParticipatingTokens, challenges, ReminderType.CHALLENGE_INVITATION); // 处理没有userId的用户 - 发送通用邀请(隔天) await this.sendInvitationReminders(anonymousTokens, challenges, ReminderType.GENERAL_INVITATION); } /** * 发送鼓励提醒给已参与挑战的用户 */ private async sendEncouragementReminders(tokens: UserPushToken[]): Promise { if (tokens.length === 0) return; this.logger.log(`Sending encouragement reminders to ${tokens.length} participating users`); let totalSent = 0; let totalFailed = 0; for (const token of tokens) { try { // 检查今天是否已经发送过鼓励推送 const today = dayjs().startOf('day').toDate(); const recentReminder = await this.reminderHistoryModel.findOne({ where: { userId: token.userId, deviceToken: token.deviceToken, reminderType: ReminderType.CHALLENGE_ENCOURAGEMENT, lastSentAt: { [Op.gte]: today, }, }, }); if (recentReminder) { this.logger.log(`User ${token.userId} already received encouragement reminder today`); continue; } // 获取用户参与的挑战信息 const participant = await this.participantModel.findOne({ where: { userId: token.userId, status: ChallengeParticipantStatus.ACTIVE, }, include: [{ model: Challenge, as: 'challenge', }], }); if (!participant || !participant.challenge) { this.logger.warn(`No active challenge found for user ${token.userId}`); continue; } // 获取鼓励文案 const template = getEncouragementTemplate(participant.challenge.type, participant.challenge.title); // 发送推送 const result = await this.pushNotificationsService.sendBatchNotificationToDevices({ deviceTokens: [token.deviceToken], title: template.title, body: template.body, pushType: PushType.alert, }); if (result.code === 0) { totalSent += result.data.successCount; totalFailed += result.data.failedCount; // 记录推送历史 await this.updateReminderHistory( token.userId, token.deviceToken, ReminderType.CHALLENGE_ENCOURAGEMENT ); this.logger.log(`Encouragement reminder sent to user ${token.userId}`); } else { totalFailed++; this.logger.warn(`Failed to send encouragement reminder to user ${token.userId}: ${result.message}`); } } catch (error) { this.logger.error(`Error sending encouragement reminder to user ${token.userId}: ${error.message}`, error); totalFailed++; } } this.logger.log(`Encouragement reminders completed. Sent: ${totalSent}, Failed: ${totalFailed}`); } /** * 发送邀请提醒给未参与挑战的用户 */ private async sendInvitationReminders( tokens: UserPushToken[], challenges: Challenge[], reminderType: ReminderType ): Promise { if (tokens.length === 0 || challenges.length === 0) return; this.logger.log(`Sending invitation reminders to ${tokens.length} users`); let totalSent = 0; let totalFailed = 0; // 随机选择一个挑战类型用于邀请 const randomChallenge = challenges[Math.floor(Math.random() * challenges.length)]; for (const token of tokens) { try { // 检查是否可以发送(隔天推送) const canSend = await this.canSendInvitation(token, reminderType); if (!canSend) { continue; } // 获取邀请文案 const template = reminderType === ReminderType.GENERAL_INVITATION ? getGeneralInvitationTemplate() : getInvitationTemplate(randomChallenge.type, randomChallenge.title); // 发送推送 const result = await this.pushNotificationsService.sendBatchNotificationToDevices({ deviceTokens: [token.deviceToken], title: template.title, body: template.body, pushType: PushType.alert, }); if (result.code === 0) { totalSent += result.data.successCount; totalFailed += result.data.failedCount; // 记录推送历史,设置下次可发送时间为2天后 await this.updateReminderHistory( token.userId, token.deviceToken, reminderType, 2 // 2天后可再次发送 ); this.logger.log(`Invitation reminder sent to ${token.userId || 'anonymous user'}`); } else { totalFailed++; this.logger.warn(`Failed to send invitation reminder: ${result.message}`); } } catch (error) { this.logger.error(`Error sending invitation reminder: ${error.message}`, error); totalFailed++; } } this.logger.log(`Invitation reminders completed. Sent: ${totalSent}, Failed: ${totalFailed}`); } /** * 检查是否可以发送邀请(隔天推送逻辑) */ private async canSendInvitation(token: UserPushToken, reminderType: ReminderType): Promise { const reminder = await this.reminderHistoryModel.findOne({ where: { userId: token.userId, deviceToken: token.deviceToken, reminderType, isActive: true, }, }); if (!reminder) { return true; // 没有记录,可以发送 } // 检查是否到了下次可发送时间 if (reminder.nextAvailableAt && dayjs().isBefore(reminder.nextAvailableAt)) { return false; } return true; } /** * 更新推送历史记录 */ private async updateReminderHistory( userId: string | null, deviceToken: string, reminderType: ReminderType, daysUntilNext = 1 ): Promise { const now = new Date(); const nextAvailableAt = dayjs().add(daysUntilNext, 'day').toDate(); const [reminder, created] = await this.reminderHistoryModel.findOrCreate({ where: { userId, deviceToken, reminderType, }, defaults: { userId, deviceToken, reminderType, lastSentAt: now, sentCount: 1, nextAvailableAt, isActive: true, }, }); if (!created) { await reminder.update({ lastSentAt: now, sentCount: reminder.sentCount + 1, nextAvailableAt, isActive: true, }); } } }