Files
plates-server/src/push-notifications/challenge-reminder.service.ts
richarjiang ac231a7742 feat(challenges): 支持自定义挑战类型并优化必填字段验证
- 新增 CUSTOM 挑战类型枚举值
- requirementLabel 字段改为可选,允许为空并添加默认值处理
- minimumCheckInDays 最大值从 365 提升至 1000,支持更长周期挑战
- 推送通知模板支持自定义挑战的动态文案生成
- 新增 getCustomEncouragementTemplate 和 getCustomInvitationTemplate 函数
2025-11-27 11:11:26 +08:00

384 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
this.logger.log('Starting daily challenge reminder task...');
try {
// 检查是否为主进程NODE_APP_INSTANCE 为 0
const nodeAppInstance = this.configService.get<number>('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<UserPushToken[]> {
return await this.pushTokenModel.findAll({
where: {
isActive: true,
},
});
}
/**
* 获取正在进行中的挑战
*/
private async getOngoingChallenges(): Promise<Challenge[]> {
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<ChallengeParticipant[]> {
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<string>,
challenges: Challenge[]
): Promise<void> {
// 分组已参与挑战的用户、未参与挑战但有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<void> {
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<void> {
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<boolean> {
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<void> {
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,
});
}
}
}