|
@@ -0,0 +1,312 @@
|
|
|
|
|
+"use strict";
|
|
|
|
|
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
|
|
|
+ if (k2 === undefined) k2 = k;
|
|
|
|
|
+ var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
|
|
|
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
|
|
|
+ desc = { enumerable: true, get: function() { return m[k]; } };
|
|
|
|
|
+ }
|
|
|
|
|
+ Object.defineProperty(o, k2, desc);
|
|
|
|
|
+}) : (function(o, m, k, k2) {
|
|
|
|
|
+ if (k2 === undefined) k2 = k;
|
|
|
|
|
+ o[k2] = m[k];
|
|
|
|
|
+}));
|
|
|
|
|
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
|
|
|
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
|
|
|
+}) : function(o, v) {
|
|
|
|
|
+ o["default"] = v;
|
|
|
|
|
+});
|
|
|
|
|
+var __importStar = (this && this.__importStar) || (function () {
|
|
|
|
|
+ var ownKeys = function(o) {
|
|
|
|
|
+ ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
|
|
|
+ var ar = [];
|
|
|
|
|
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
|
|
|
+ return ar;
|
|
|
|
|
+ };
|
|
|
|
|
+ return ownKeys(o);
|
|
|
|
|
+ };
|
|
|
|
|
+ return function (mod) {
|
|
|
|
|
+ if (mod && mod.__esModule) return mod;
|
|
|
|
|
+ var result = {};
|
|
|
|
|
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
|
|
|
+ __setModuleDefault(result, mod);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ };
|
|
|
|
|
+})();
|
|
|
|
|
+Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
|
|
+exports.run = run;
|
|
|
|
|
+const mongoose_1 = __importStar(require("mongoose"));
|
|
|
|
|
+const userModel_1 = require("../models/userModel");
|
|
|
|
|
+// 确保先导入并注册 MessageTemplate 模型
|
|
|
|
|
+const messageTemplateModel_1 = require("../models/messageTemplateModel");
|
|
|
|
|
+// 确保 MessageTemplate 模型已被注册
|
|
|
|
|
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
|
|
|
|
|
+// 然后再导入和使用 MessageStrategy 模型
|
|
|
|
|
+const messageStrategyModel_1 = require("../models/messageStrategyModel");
|
|
|
|
|
+const messageRecordModel_1 = require("../models/messageRecordModel");
|
|
|
|
|
+const fcmService_1 = require("../services/fcmService");
|
|
|
|
|
+const database_1 = require("../../src/database");
|
|
|
|
|
+const strategyName = "active_new_content_notify";
|
|
|
|
|
+const fcmService = fcmService_1.FCMService.getInstance();
|
|
|
|
|
+const ArtSchema = new mongoose_1.Schema({
|
|
|
|
|
+ tags: [{ type: String }],
|
|
|
|
|
+ publishTime: { type: Date, required: true },
|
|
|
|
|
+});
|
|
|
|
|
+const Art = mongoose_1.default.model("Art", ArtSchema, "arts");
|
|
|
|
|
+// 国家代码到语言的映射表,包含您提供的 top10 国家,可根据需求扩展
|
|
|
|
|
+const countryCodeToLanguageMap = {
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ 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, userLang) {
|
|
|
|
|
+ // 优先使用用户的语言,如果没有,则回退到默认语言 '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 || "",
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 获取今日的用于消息推送的2幅画作。
|
|
|
|
|
+ * 查询 art 表,查找 tags 标签等于 'fcm' 的记录,按 publishTime 倒序排,取头部的2个 art。
|
|
|
|
|
+ * @returns 包含两个画作 ID 的数组 [art_id1, art_id2]
|
|
|
|
|
+ */
|
|
|
|
|
+async function getTodaysArtworksForFCM() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const artworks = await Art.find({ tags: "fcm" })
|
|
|
|
|
+ // const artworks = await Art.find({})
|
|
|
|
|
+ .sort({ publishTime: -1 }) // 倒序排序
|
|
|
|
|
+ .limit(2) // 限制为2个
|
|
|
|
|
+ .lean();
|
|
|
|
|
+ // 如果找到,返回 ID 数组;如果不足2个,则返回找到的所有 ID
|
|
|
|
|
+ return artworks.map((art) => art._id.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (error) {
|
|
|
|
|
+ console.error("查询今日画作失败:", error);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 检查错误是否为 FCM 令牌失效错误。
|
|
|
|
|
+ * @param error 错误对象
|
|
|
|
|
+ * @returns 如果是令牌失效错误则返回 true
|
|
|
|
|
+ */
|
|
|
|
|
+function isTokenInvalidationError(error) {
|
|
|
|
|
+ return error && (error.code === "messaging/registration-token-not-registered" || error.code === "messaging/invalid-registration-token");
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 发送并记录FCM消息。
|
|
|
|
|
+ * 此函数现在将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
|
|
|
|
|
+ * @param uid 用户ID
|
|
|
|
|
+ * @param fcmToken 用户的FCM Token
|
|
|
|
|
+ * @param template 消息模板
|
|
|
|
|
+ * @param messageData 消息数据
|
|
|
|
|
+ * @param strategyId 消息策略ID
|
|
|
|
|
+ * @param strategyName 消息策略名称
|
|
|
|
|
+ */
|
|
|
|
|
+const sendAndRecordMessage = async (uid, fcmToken, template, messageData, strategyId, strategyName) => {
|
|
|
|
|
+ let messageRecord = null;
|
|
|
|
|
+ let fcmReceipt = null;
|
|
|
|
|
+ let messageStatus = 0;
|
|
|
|
|
+ let errorMessage = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
|
|
|
|
|
+ messageRecord = await messageRecordModel_1.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,
|
|
|
|
|
+ });
|
|
|
|
|
+ // 2. 将记录的 _id 添加到消息数据中作为 msgid
|
|
|
|
|
+ const finalMessageData = {
|
|
|
|
|
+ ...messageData,
|
|
|
|
|
+ msgid: messageRecord._id.toString(),
|
|
|
|
|
+ };
|
|
|
|
|
+ // 3. 尝试发送消息
|
|
|
|
|
+ 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.errorInfo;
|
|
|
|
|
+ const isInvalidToken = isTokenInvalidationError(error);
|
|
|
|
|
+ messageStatus = -1;
|
|
|
|
|
+ errorMessage = errorInfo ? errorInfo.code : error.message;
|
|
|
|
|
+ if (isInvalidToken) {
|
|
|
|
|
+ // 如果是无效令牌错误,清空该用户的 fmToken
|
|
|
|
|
+ await userModel_1.User.findByIdAndUpdate(uid, { fmToken: null });
|
|
|
|
|
+ console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
|
|
|
|
|
+ errorMessage += " (Token cleared)";
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ console.error(`发送消息给用户 ${uid} 失败:`, error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ finally {
|
|
|
|
|
+ // 4. 不论成功与否,更新 MessageRecord 的状态和结果
|
|
|
|
|
+ if (messageRecord) {
|
|
|
|
|
+ await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
|
|
|
|
|
+ status: messageStatus,
|
|
|
|
|
+ fcmReceipt: fcmReceipt,
|
|
|
|
|
+ actualSendAt: new Date(),
|
|
|
|
|
+ errno: errorMessage,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+/**
|
|
|
|
|
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
|
|
|
|
|
+ * 此方法通过cron外部调用。
|
|
|
|
|
+ */
|
|
|
|
|
+async function run() {
|
|
|
|
|
+ console.log("脚本开始:发送活跃用户每日通知...");
|
|
|
|
|
+ // 在启动所有定时任务之前,首先建立数据库连接
|
|
|
|
|
+ await (0, database_1.connectToDatabase)();
|
|
|
|
|
+ try {
|
|
|
|
|
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
|
+ // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
|
|
|
|
|
+ const activeUsers = await userModel_1.User.find({
|
|
|
|
|
+ // lastActiveAt: { $gte: sevenDaysAgo },
|
|
|
|
|
+ fmToken: { $nin: [null, ""] },
|
|
|
|
|
+ versionName: { $in: ["5.8.0-debug"] },
|
|
|
|
|
+ // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
|
|
|
|
|
+ })
|
|
|
|
|
+ .select("_id fmToken lang cc")
|
|
|
|
|
+ .lean();
|
|
|
|
|
+ if (activeUsers.length === 0) {
|
|
|
|
|
+ console.log("未找到符合条件的用户,脚本结束。");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log(`找到 ${activeUsers.length} 位活跃用户。`);
|
|
|
|
|
+ const strategy = await messageStrategyModel_1.MessageStrategy.findOne({ name: strategyName }).populate("templates");
|
|
|
|
|
+ if (!strategy || !strategy.templates || strategy.templates.length < 2) {
|
|
|
|
|
+ console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板,或模板数量不足2个。`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const templates = strategy.templates;
|
|
|
|
|
+ console.log(`找到 ${templates.length} 个消息模板。`);
|
|
|
|
|
+ const todaysArtworks = await getTodaysArtworksForFCM();
|
|
|
|
|
+ if (todaysArtworks.length < 2) {
|
|
|
|
|
+ console.warn("今日用于FCM消息推送的画作数量不足2个,无法执行双消息策略。脚本结束。");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const artwork1Id = todaysArtworks[0];
|
|
|
|
|
+ const artwork2Id = todaysArtworks[1];
|
|
|
|
|
+ console.log(`今日画作ID:${artwork1Id} 和 ${artwork2Id}`);
|
|
|
|
|
+ // 为每个用户预先选择并存储两条消息的模板
|
|
|
|
|
+ const messagesToSend = activeUsers.map((user) => {
|
|
|
|
|
+ const randomIndex1 = Math.floor(Math.random() * templates.length);
|
|
|
|
|
+ let randomIndex2 = Math.floor(Math.random() * templates.length);
|
|
|
|
|
+ while (randomIndex2 === randomIndex1) {
|
|
|
|
|
+ randomIndex2 = Math.floor(Math.random() * templates.length);
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ user,
|
|
|
|
|
+ template1: templates[randomIndex1],
|
|
|
|
|
+ template2: templates[randomIndex2],
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ // --- 立即发送第一批消息 ---
|
|
|
|
|
+ console.log("\n开始发送第一批消息...");
|
|
|
|
|
+ for (const messageData of messagesToSend) {
|
|
|
|
|
+ const user = messageData.user;
|
|
|
|
|
+ const userLang = getUserLanguage(user);
|
|
|
|
|
+ const fcmToken = user.fmToken;
|
|
|
|
|
+ const data1 = getMessageDataFromTemplate(messageData.template1, userLang);
|
|
|
|
|
+ data1.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artwork1Id}.png`;
|
|
|
|
|
+ data1.bigger = "true";
|
|
|
|
|
+ data1.action = "go/art";
|
|
|
|
|
+ data1.param = artwork1Id;
|
|
|
|
|
+ await sendAndRecordMessage(user._id, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log("第一批消息发送完成。");
|
|
|
|
|
+ // --- 设置单个定时器,延迟30分钟后发送第二批消息 ---
|
|
|
|
|
+ setTimeout(async () => {
|
|
|
|
|
+ console.log("\n定时任务触发:开始发送第二批消息...");
|
|
|
|
|
+ for (const messageData of messagesToSend) {
|
|
|
|
|
+ const user = messageData.user;
|
|
|
|
|
+ const userLang = getUserLanguage(user);
|
|
|
|
|
+ const fcmToken = user.fmToken;
|
|
|
|
|
+ const data2 = getMessageDataFromTemplate(messageData.template2, userLang);
|
|
|
|
|
+ data2.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artwork2Id}.png`;
|
|
|
|
|
+ data2.bigger = "true";
|
|
|
|
|
+ data2.action = "go/art";
|
|
|
|
|
+ data2.param = artwork2Id;
|
|
|
|
|
+ await sendAndRecordMessage(user._id, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log("第二批消息发送完成。");
|
|
|
|
|
+ }, 30 * 60 * 1000);
|
|
|
|
|
+ console.log("脚本执行完毕。第二批消息将在30分钟后发送。");
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (error) {
|
|
|
|
|
+ console.error("脚本执行过程中发生致命错误:", error);
|
|
|
|
|
+ }
|
|
|
|
|
+ finally {
|
|
|
|
|
+ // 脚本执行完毕,您可以在这里调用数据库断开连接
|
|
|
|
|
+ await (0, database_1.disconnectFromDatabase)(); // 在退出前断开数据库连接
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
|
|
|
|
|
+if (require.main === module) {
|
|
|
|
|
+ run()
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ console.log("脚本执行完毕,退出进程。");
|
|
|
|
|
+ process.exit(0);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((err) => {
|
|
|
|
|
+ console.error("脚本执行失败:", err);
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|