|
|
@@ -0,0 +1,347 @@
|
|
|
+import mongoose, { Schema, Document } from "mongoose";
|
|
|
+import { User, IUser } from "../../src/models/userModel";
|
|
|
+import { IMessageTemplate, MessageTemplate } from "../../src/models/messageTemplateModel";
|
|
|
+mongoose.model("MessageTemplate", MessageTemplate.schema);
|
|
|
+import { MessageStrategy, IMessageStrategy } from "../../src/models/messageStrategyModel";
|
|
|
+import { MessageRecord, IMessageRecord } from "../../src/models/messageRecordModel";
|
|
|
+import { FCMService } from "../../src/services/fcmService";
|
|
|
+import Art, { IArt } from "../../src/models/artModel";
|
|
|
+import { shuffle } from "lodash"; // 使用 lodash 的 shuffle 函数来打乱数组
|
|
|
+
|
|
|
+// 定义两个不同的策略名称
|
|
|
+const immediateStrategyName = "active_new_content_notify";
|
|
|
+const scheduledStrategyName = "notify-by-last-active";
|
|
|
+const fcmService = FCMService.getInstance();
|
|
|
+
|
|
|
+// 国家代码到语言的映射表
|
|
|
+const countryCodeToLanguageMap: { [key: string]: string } = {
|
|
|
+ CN: "zh-cn",
|
|
|
+ US: "en",
|
|
|
+ JP: "ja",
|
|
|
+ FR: "fr",
|
|
|
+ DE: "de",
|
|
|
+ ES: "es", // Spain
|
|
|
+ MX: "es", // Mexico
|
|
|
+ CL: "es", // Chile
|
|
|
+ BR: "pt", // Brazil
|
|
|
+ RU: "ru", // Russia
|
|
|
+ IN: "hi", // India
|
|
|
+ ID: "id", // Indonesia
|
|
|
+ IT: "it",
|
|
|
+ KR: "ko",
|
|
|
+ TH: "th",
|
|
|
+ TR: "tr",
|
|
|
+ VN: "vi",
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 根据用户的 lang 或 cc 字段推断其语言。
|
|
|
+ * @param user 用户对象
|
|
|
+ * @returns 推断出的语言代码
|
|
|
+ */
|
|
|
+function getUserLanguage(user: IUser): string {
|
|
|
+ if (user.lang) {
|
|
|
+ return user.lang;
|
|
|
+ }
|
|
|
+ if (user.cc && countryCodeToLanguageMap[user.cc]) {
|
|
|
+ return countryCodeToLanguageMap[user.cc];
|
|
|
+ }
|
|
|
+ return "en"; // 最终默认语言为英语
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 将多语言模板转换为 FCM 消息数据格式。
|
|
|
+ * @param template 消息模板
|
|
|
+ * @param userLang 用户的语言代码
|
|
|
+ */
|
|
|
+function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string): { [key: string]: string } {
|
|
|
+ // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
|
|
|
+ const lang = template.messageContent[userLang] ? userLang : "en";
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: template.messageTitle[lang] || template.messageTitle["en"] || "",
|
|
|
+ content: template.messageContent[lang] || template.messageContent["en"] || "",
|
|
|
+ image: template.image || "",
|
|
|
+ bigger: String(template.bigger || false),
|
|
|
+ action: template.action || "go/app",
|
|
|
+ param: template.param || "",
|
|
|
+ extend: template.extend || "",
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取今日的用于消息推送的1幅画作。
|
|
|
+ * 查询 art 表,查找 tags 标签等于 'fcm' 的记录,按 publishTime 倒序排,取头部的1个 art。
|
|
|
+ * @returns 包含一个画作 ID 的数组 [art_id1]
|
|
|
+ */
|
|
|
+async function getTodaysArtworksForFCM(): Promise<string[]> {
|
|
|
+ try {
|
|
|
+ const artworks = await Art.find({ tags: "fcm" }).sort({ publishTime: -1 }).limit(1).lean<IArt[]>();
|
|
|
+
|
|
|
+ return artworks.map((art) => art._id.toString());
|
|
|
+ } catch (error) {
|
|
|
+ console.error("查询今日画作失败:", error);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 检查错误是否为 FCM 令牌失效错误。
|
|
|
+ * @param error 错误对象
|
|
|
+ * @returns 如果是令牌失效错误则返回 true
|
|
|
+ */
|
|
|
+function isTokenInvalidationError(error: any): boolean {
|
|
|
+ return error && (error.code === "messaging/registration-token-not-registered" || error.code === "messaging/invalid-registration-token");
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 发送即时消息并记录。
|
|
|
+ * 此函数将创建数据库记录并立即调用FCM API发送。
|
|
|
+ * @param uid 用户ID
|
|
|
+ * @param fcmToken 用户的FCM Token
|
|
|
+ * @param template 消息模板
|
|
|
+ * @param messageData 消息数据
|
|
|
+ * @param strategyId 消息策略ID
|
|
|
+ * @param strategyName 消息策略名称
|
|
|
+ */
|
|
|
+const sendImmediateMessageAndRecord = async (
|
|
|
+ uid: string,
|
|
|
+ fcmToken: string,
|
|
|
+ template: IMessageTemplate,
|
|
|
+ messageData: { [key: string]: string },
|
|
|
+ strategyId: mongoose.Types.ObjectId,
|
|
|
+ strategyName: string
|
|
|
+) => {
|
|
|
+ let messageRecord: IMessageRecord | null = null;
|
|
|
+ let fcmReceipt = null;
|
|
|
+ let messageStatus = 0;
|
|
|
+ let errorMessage = null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ messageRecord = await MessageRecord.create({
|
|
|
+ uid: uid,
|
|
|
+ templateId: template._id,
|
|
|
+ templateName: template.templateName,
|
|
|
+ strategyId: strategyId,
|
|
|
+ strategyName: strategyName,
|
|
|
+ title: messageData.title,
|
|
|
+ content: messageData.content,
|
|
|
+ image: messageData.image,
|
|
|
+ bigger: messageData.bigger === "true",
|
|
|
+ action: messageData.action,
|
|
|
+ param: messageData.param,
|
|
|
+ extend: messageData.extend,
|
|
|
+ plannedSendAt: new Date(),
|
|
|
+ status: 0,
|
|
|
+ });
|
|
|
+
|
|
|
+ const finalMessageData = {
|
|
|
+ ...messageData,
|
|
|
+ msgid: messageRecord._id.toString(),
|
|
|
+ };
|
|
|
+
|
|
|
+ const sendResult = await fcmService.sendMessage(fcmToken, finalMessageData);
|
|
|
+ if (sendResult instanceof Error) {
|
|
|
+ throw sendResult;
|
|
|
+ }
|
|
|
+ fcmReceipt = sendResult;
|
|
|
+ messageStatus = 1;
|
|
|
+
|
|
|
+ console.log(`成功发送消息给用户 ${uid}。`);
|
|
|
+ } catch (error) {
|
|
|
+ const errorInfo = (error as any).errorInfo;
|
|
|
+ const isInvalidToken = isTokenInvalidationError(error);
|
|
|
+
|
|
|
+ messageStatus = -1;
|
|
|
+ errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
|
|
|
+
|
|
|
+ if (isInvalidToken) {
|
|
|
+ await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
|
|
|
+ console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
|
|
|
+ errorMessage += " (Token cleared)";
|
|
|
+ } else {
|
|
|
+ console.error(`发送消息给用户 ${uid} 失败:`, error);
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (messageRecord) {
|
|
|
+ await MessageRecord.findByIdAndUpdate(messageRecord._id, {
|
|
|
+ status: messageStatus,
|
|
|
+ fcmReceipt: fcmReceipt as any,
|
|
|
+ actualSendAt: new Date(),
|
|
|
+ errno: errorMessage,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 创建定时消息记录。
|
|
|
+ * 此函数只负责创建数据库记录,不进行FCM发送。
|
|
|
+ * @param user 用户对象
|
|
|
+ * @param template 消息模板
|
|
|
+ * @param messageData 消息数据
|
|
|
+ * @param strategyId 消息策略ID
|
|
|
+ * @param strategyName 消息策略名称
|
|
|
+ * @param plannedSendAt 计划发送时间
|
|
|
+ */
|
|
|
+const createScheduledMessageRecord = async (
|
|
|
+ user: IUser,
|
|
|
+ template: IMessageTemplate,
|
|
|
+ messageData: { [key: string]: string },
|
|
|
+ strategyId: mongoose.Types.ObjectId,
|
|
|
+ strategyName: string,
|
|
|
+ plannedSendAt: Date
|
|
|
+) => {
|
|
|
+ try {
|
|
|
+ await MessageRecord.create({
|
|
|
+ uid: user.uid,
|
|
|
+ templateId: template._id,
|
|
|
+ templateName: template.templateName,
|
|
|
+ strategyId: strategyId,
|
|
|
+ strategyName: strategyName,
|
|
|
+ title: messageData.title,
|
|
|
+ content: messageData.content,
|
|
|
+ image: messageData.image,
|
|
|
+ bigger: messageData.bigger === "true",
|
|
|
+ action: messageData.action,
|
|
|
+ param: messageData.param,
|
|
|
+ extend: messageData.extend,
|
|
|
+ plannedSendAt: plannedSendAt,
|
|
|
+ // status 保持默认的 0(未发送)
|
|
|
+ status: 0,
|
|
|
+ });
|
|
|
+ console.log(`成功为用户 ${user.uid} 创建定时推送记录,计划发送时间: ${plannedSendAt.toISOString()}`);
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`为用户 ${user.uid} 创建定时推送记录失败:`, error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
|
|
|
+ * 此方法通过cron外部调用。
|
|
|
+ *
|
|
|
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务完成后解决。
|
|
|
+ */
|
|
|
+export async function run(): Promise<void> {
|
|
|
+ console.log("脚本开始:发送活跃用户每日通知...");
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. 获取所有需要的策略和模板
|
|
|
+ const immediateStrategy = await MessageStrategy.findOne({ name: immediateStrategyName }).populate("templates");
|
|
|
+ const scheduledStrategy = await MessageStrategy.findOne({ name: scheduledStrategyName }).populate("templates");
|
|
|
+
|
|
|
+ if (!immediateStrategy || !immediateStrategy.templates || immediateStrategy.templates.length < 1) {
|
|
|
+ console.error(`未找到即时策略 '${immediateStrategyName}' 或其绑定的消息模板。`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!scheduledStrategy || !scheduledStrategy.templates || scheduledStrategy.templates.length < 1) {
|
|
|
+ console.error(`未找到定时策略 '${scheduledStrategyName}' 或其绑定的消息模板。`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const immediateTemplates = immediateStrategy.templates as IMessageTemplate[];
|
|
|
+ const scheduledTemplates = scheduledStrategy.templates as IMessageTemplate[];
|
|
|
+
|
|
|
+ // 2. 获取今日画作
|
|
|
+ const todaysArtworks = await getTodaysArtworksForFCM();
|
|
|
+ if (todaysArtworks.length === 0) {
|
|
|
+ console.warn("今日用于FCM消息推送的画作数量为0。脚本结束。");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const artworkId = todaysArtworks[0];
|
|
|
+ console.log(`今日画作ID:${artworkId}`);
|
|
|
+
|
|
|
+ // 3. 筛选并分发用户
|
|
|
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
|
+ const activeUsers = await User.find({
|
|
|
+ lastActiveAt: { $gte: sevenDaysAgo },
|
|
|
+ fmToken: { $nin: [null, ""] },
|
|
|
+ $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
|
|
|
+ })
|
|
|
+ .select("_id uid fmToken lang cc lastActiveAt")
|
|
|
+ .lean<IUser[]>();
|
|
|
+
|
|
|
+ if (activeUsers.length === 0) {
|
|
|
+ console.log("未找到符合条件的用户,脚本结束。");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ console.log(`找到 ${activeUsers.length} 位活跃用户。`);
|
|
|
+
|
|
|
+ // 随机打乱用户列表以进行 A/B 测试
|
|
|
+ const shuffledUsers = shuffle(activeUsers);
|
|
|
+ const splitIndex = Math.floor(shuffledUsers.length / 2);
|
|
|
+ const immediateUsers = shuffledUsers.slice(0, splitIndex);
|
|
|
+ const scheduledUsers = shuffledUsers.slice(splitIndex);
|
|
|
+
|
|
|
+ console.log(`分为两组:即时发送组 ${immediateUsers.length} 人,定时发送组 ${scheduledUsers.length} 人。`);
|
|
|
+
|
|
|
+ // 4. 发送第一批消息 (即时发送组)
|
|
|
+ console.log("\n开始处理即时发送组...");
|
|
|
+ for (const user of immediateUsers) {
|
|
|
+ const userLang = getUserLanguage(user);
|
|
|
+ const fcmToken = user.fmToken as string;
|
|
|
+ const template = immediateTemplates[Math.floor(Math.random() * immediateTemplates.length)];
|
|
|
+ const data = getMessageDataFromTemplate(template, userLang);
|
|
|
+
|
|
|
+ // 随机选择 fcm 或 page 路径
|
|
|
+ const isFcmPath = Math.random() < 0.5;
|
|
|
+ data.image = isFcmPath ? `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/fcm/640/${artworkId}.png` : `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
|
|
|
+
|
|
|
+ data.bigger = "true";
|
|
|
+ data.action = "go/art";
|
|
|
+ data.param = artworkId;
|
|
|
+
|
|
|
+ await sendImmediateMessageAndRecord(user.uid, fcmToken, template, data, immediateStrategy._id, immediateStrategy.name);
|
|
|
+ }
|
|
|
+ console.log("即时发送组处理完成。");
|
|
|
+
|
|
|
+ // 5. 创建第二批消息记录 (定时发送组)
|
|
|
+ console.log("\n开始为定时发送组创建任务记录...");
|
|
|
+ for (const user of scheduledUsers) {
|
|
|
+ const userLang = getUserLanguage(user);
|
|
|
+ const template = scheduledTemplates[Math.floor(Math.random() * scheduledTemplates.length)];
|
|
|
+ const data = getMessageDataFromTemplate(template, userLang);
|
|
|
+
|
|
|
+ // 随机选择 fcm 或 page 路径
|
|
|
+ const isFcmPath = Math.random() < 0.5;
|
|
|
+ data.image = isFcmPath ? `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/fcm/640/${artworkId}.png` : `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
|
|
|
+
|
|
|
+ data.bigger = "true";
|
|
|
+ data.action = "go/art";
|
|
|
+ data.param = artworkId;
|
|
|
+
|
|
|
+ // 根据今天的日期和用户的 lastActiveAt 时间计算 plannedSendAt
|
|
|
+ const now = new Date();
|
|
|
+ const lastActiveAt = new Date(user.lastActiveAt!);
|
|
|
+
|
|
|
+ const plannedSendAt = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
+ plannedSendAt.setHours(lastActiveAt.getHours() - 1);
|
|
|
+ plannedSendAt.setMinutes(lastActiveAt.getMinutes());
|
|
|
+ plannedSendAt.setSeconds(lastActiveAt.getSeconds());
|
|
|
+ plannedSendAt.setMilliseconds(lastActiveAt.getMilliseconds());
|
|
|
+
|
|
|
+ // 如果计算出的时间已在过去,则推迟到第二天
|
|
|
+ if (plannedSendAt.getTime() < now.getTime()) {
|
|
|
+ plannedSendAt.setDate(plannedSendAt.getDate() + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ await createScheduledMessageRecord(user, template, data, scheduledStrategy._id, scheduledStrategy.name, plannedSendAt);
|
|
|
+ }
|
|
|
+ console.log("定时发送组任务记录创建完成。");
|
|
|
+ } catch (error) {
|
|
|
+ console.error("脚本执行过程中发生致命错误:", error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+if (require.main === module) {
|
|
|
+ run()
|
|
|
+ .then(() => {
|
|
|
+ console.log("脚本执行完毕,退出进程。");
|
|
|
+ process.exit(0);
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ console.error("脚本执行失败:", err);
|
|
|
+ process.exit(1);
|
|
|
+ });
|
|
|
+}
|