- 新增 CUSTOM 挑战类型枚举值 - requirementLabel 字段改为可选,允许为空并添加默认值处理 - minimumCheckInDays 最大值从 365 提升至 1000,支持更长周期挑战 - 推送通知模板支持自定义挑战的动态文案生成 - 新增 getCustomEncouragementTemplate 和 getCustomInvitationTemplate 函数
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|
||
} |