Просмотр исходного кода

调整推送策略,分上中下晚推送

guoziyun 9 месяцев назад
Родитель
Сommit
6b73f73bfc

+ 5 - 1
oms/dist/services/cron-jobs/index.js

@@ -15,7 +15,11 @@ const settings = [
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
     // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
     // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
-    ["fcm-notify", "30 18 * * *", require("./fcm-notify")], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
+    // ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
+    ["daily-notify-at-morning", "0 19 * * *", require("./notify/daily-notify-at-morning")], // 每天下午7点,对应巴西时间早上8点推送一轮
+    ["daily-notify-at-midday", "0 23 * * *", require("./notify/daily-notify-at-midday")], // 每天下午7点,对应巴西时间早上8点推送一轮
+    ["daily-notify-at-evening", "0 3 * * *", require("./notify/daily-notify-at-evening")], // 每天凌晨3点,对应巴西时间下午4点
+    ["daily-notify-at-afternoon", "0 7 * * *", require("./notify/daily-notify-at-afternoon")], // 每天上午7点,对应巴西时间晚上8点
 ];
 /**
  * Starts all scheduled cron jobs.

+ 248 - 0
oms/dist/services/cron-jobs/notify/daily-notify-at-afternoon.js

@@ -0,0 +1,248 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const mongoose_1 = __importDefault(require("mongoose"));
+const userModel_1 = require("../../../src/models/userModel");
+// 确保先导入并注册 MessageTemplate 模型
+const messageTemplateModel_1 = require("../../../src/models/messageTemplateModel");
+// 确保 MessageTemplate 模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+// 然后再导入和使用 MessageStrategy 模型
+const messageStrategyModel_1 = require("../../../src/models/messageStrategyModel");
+const messageRecordModel_1 = require("../../../src/models/messageRecordModel");
+const fcmService_1 = require("../../../src/services/fcmService");
+const artModel_1 = __importDefault(require("../../../src/models/artModel"));
+const strategyName = "daily-notify-at-afternoon";
+const fcmService = fcmService_1.FCMService.getInstance();
+// 国家代码到语言的映射表
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template, userLang, artworkId) {
+    // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+    const lang = template.messageContent[userLang] ? userLang : "en";
+    // 统一使用 page 路径
+    const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+    return {
+        title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+        content: template.messageContent[lang] || template.messageContent["en"] || "",
+        image: imageUrl,
+        bigger: "true",
+        action: "go/art",
+        param: artworkId,
+        extend: template.extend || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM() {
+    try {
+        const artworks = await artModel_1.default.find({}).sort({ publishTime: -1 }).limit(8).lean();
+        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 cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (uid, cc, 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,
+            cc: cc,
+            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.findOneAndUpdate({ uid: 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外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        // 筛选条件调整:用户最近12小时不活跃
+        const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+        // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+        const activeUsers = await userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+            fmToken: { $nin: [null, ""] },
+            //   versionName: { $in: ["5.8.0-debug"] },
+            // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+            versionCode: { $gte: 347 },
+            // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+        })
+            .select("_id uid 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 === 0) {
+            console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+            return;
+        }
+        const templates = strategy.templates;
+        console.log(`找到 ${templates.length} 个消息模板。`);
+        // 调整为获取最新的8个画作,用于随机选择
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length === 0) {
+            console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+            return;
+        }
+        console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+        // 遍历用户,发送一轮消息
+        console.log("\n开始发送第一轮消息...");
+        for (const user of activeUsers) {
+            const userLang = getUserLanguage(user);
+            const fcmToken = user.fmToken;
+            // 随机选择一个模板
+            const randomIndex = Math.floor(Math.random() * templates.length);
+            const template = templates[randomIndex];
+            // 从获取的8幅画作中随机选择一个
+            const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+            const artworkId = todaysArtworks[randomArtworkIndex];
+            const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+            await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+        }
+        console.log("消息发送完成。");
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 248 - 0
oms/dist/services/cron-jobs/notify/daily-notify-at-evening.js

@@ -0,0 +1,248 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const mongoose_1 = __importDefault(require("mongoose"));
+const userModel_1 = require("../../../src/models/userModel");
+// 确保先导入并注册 MessageTemplate 模型
+const messageTemplateModel_1 = require("../../../src/models/messageTemplateModel");
+// 确保 MessageTemplate 模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+// 然后再导入和使用 MessageStrategy 模型
+const messageStrategyModel_1 = require("../../../src/models/messageStrategyModel");
+const messageRecordModel_1 = require("../../../src/models/messageRecordModel");
+const fcmService_1 = require("../../../src/services/fcmService");
+const artModel_1 = __importDefault(require("../../../src/models/artModel"));
+const strategyName = "daily-notify-at-evening";
+const fcmService = fcmService_1.FCMService.getInstance();
+// 国家代码到语言的映射表
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template, userLang, artworkId) {
+    // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+    const lang = template.messageContent[userLang] ? userLang : "en";
+    // 统一使用 page 路径
+    const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+    return {
+        title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+        content: template.messageContent[lang] || template.messageContent["en"] || "",
+        image: imageUrl,
+        bigger: "true",
+        action: "go/art",
+        param: artworkId,
+        extend: template.extend || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM() {
+    try {
+        const artworks = await artModel_1.default.find({}).sort({ publishTime: -1 }).limit(8).lean();
+        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 cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (uid, cc, 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,
+            cc: cc,
+            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.findOneAndUpdate({ uid: 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外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        // 筛选条件调整:用户最近12小时不活跃
+        const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+        // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+        const activeUsers = await userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+            fmToken: { $nin: [null, ""] },
+            //   versionName: { $in: ["5.8.0-debug"] },
+            // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+            versionCode: { $gte: 347 },
+            // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+        })
+            .select("_id uid 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 === 0) {
+            console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+            return;
+        }
+        const templates = strategy.templates;
+        console.log(`找到 ${templates.length} 个消息模板。`);
+        // 调整为获取最新的8个画作,用于随机选择
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length === 0) {
+            console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+            return;
+        }
+        console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+        // 遍历用户,发送一轮消息
+        console.log("\n开始发送第一轮消息...");
+        for (const user of activeUsers) {
+            const userLang = getUserLanguage(user);
+            const fcmToken = user.fmToken;
+            // 随机选择一个模板
+            const randomIndex = Math.floor(Math.random() * templates.length);
+            const template = templates[randomIndex];
+            // 从获取的8幅画作中随机选择一个
+            const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+            const artworkId = todaysArtworks[randomArtworkIndex];
+            const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+            await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+        }
+        console.log("消息发送完成。");
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 248 - 0
oms/dist/services/cron-jobs/notify/daily-notify-at-midday.js

@@ -0,0 +1,248 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const mongoose_1 = __importDefault(require("mongoose"));
+const userModel_1 = require("../../../src/models/userModel");
+// 确保先导入并注册 MessageTemplate 模型
+const messageTemplateModel_1 = require("../../../src/models/messageTemplateModel");
+// 确保 MessageTemplate 模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+// 然后再导入和使用 MessageStrategy 模型
+const messageStrategyModel_1 = require("../../../src/models/messageStrategyModel");
+const messageRecordModel_1 = require("../../../src/models/messageRecordModel");
+const fcmService_1 = require("../../../src/services/fcmService");
+const artModel_1 = __importDefault(require("../../../src/models/artModel"));
+const strategyName = "daily-notify-at-midday";
+const fcmService = fcmService_1.FCMService.getInstance();
+// 国家代码到语言的映射表
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template, userLang, artworkId) {
+    // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+    const lang = template.messageContent[userLang] ? userLang : "en";
+    // 统一使用 page 路径
+    const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+    return {
+        title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+        content: template.messageContent[lang] || template.messageContent["en"] || "",
+        image: imageUrl,
+        bigger: "true",
+        action: "go/art",
+        param: artworkId,
+        extend: template.extend || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM() {
+    try {
+        const artworks = await artModel_1.default.find({}).sort({ publishTime: -1 }).limit(8).lean();
+        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 cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (uid, cc, 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,
+            cc: cc,
+            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.findOneAndUpdate({ uid: 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外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        // 筛选条件调整:用户最近12小时不活跃
+        const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+        // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+        const activeUsers = await userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+            fmToken: { $nin: [null, ""] },
+            //   versionName: { $in: ["5.8.0-debug"] },
+            // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+            versionCode: { $gte: 347 },
+            // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+        })
+            .select("_id uid 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 === 0) {
+            console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+            return;
+        }
+        const templates = strategy.templates;
+        console.log(`找到 ${templates.length} 个消息模板。`);
+        // 调整为获取最新的8个画作,用于随机选择
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length === 0) {
+            console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+            return;
+        }
+        console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+        // 遍历用户,发送一轮消息
+        console.log("\n开始发送第一轮消息...");
+        for (const user of activeUsers) {
+            const userLang = getUserLanguage(user);
+            const fcmToken = user.fmToken;
+            // 随机选择一个模板
+            const randomIndex = Math.floor(Math.random() * templates.length);
+            const template = templates[randomIndex];
+            // 从获取的8幅画作中随机选择一个
+            const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+            const artworkId = todaysArtworks[randomArtworkIndex];
+            const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+            await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+        }
+        console.log("消息发送完成。");
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 248 - 0
oms/dist/services/cron-jobs/notify/daily-notify-at-morning.js

@@ -0,0 +1,248 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const mongoose_1 = __importDefault(require("mongoose"));
+const userModel_1 = require("../../../src/models/userModel");
+// 确保先导入并注册 MessageTemplate 模型
+const messageTemplateModel_1 = require("../../../src/models/messageTemplateModel");
+// 确保 MessageTemplate 模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+// 然后再导入和使用 MessageStrategy 模型
+const messageStrategyModel_1 = require("../../../src/models/messageStrategyModel");
+const messageRecordModel_1 = require("../../../src/models/messageRecordModel");
+const fcmService_1 = require("../../../src/services/fcmService");
+const artModel_1 = __importDefault(require("../../../src/models/artModel"));
+const strategyName = "daily-notify-at-morning";
+const fcmService = fcmService_1.FCMService.getInstance();
+// 国家代码到语言的映射表
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template, userLang, artworkId) {
+    // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+    const lang = template.messageContent[userLang] ? userLang : "en";
+    // 统一使用 page 路径
+    const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+    return {
+        title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+        content: template.messageContent[lang] || template.messageContent["en"] || "",
+        image: imageUrl,
+        bigger: "true",
+        action: "go/art",
+        param: artworkId,
+        extend: template.extend || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM() {
+    try {
+        const artworks = await artModel_1.default.find({}).sort({ publishTime: -1 }).limit(8).lean();
+        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 cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (uid, cc, 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,
+            cc: cc,
+            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.findOneAndUpdate({ uid: 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外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        // 筛选条件调整:用户最近12小时不活跃
+        const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+        // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+        const activeUsers = await userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+            fmToken: { $nin: [null, ""] },
+            //   versionName: { $in: ["5.8.0-debug"] },
+            // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+            versionCode: { $gte: 347 },
+            // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+        })
+            .select("_id uid 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 === 0) {
+            console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+            return;
+        }
+        const templates = strategy.templates;
+        console.log(`找到 ${templates.length} 个消息模板。`);
+        // 调整为获取最新的8个画作,用于随机选择
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length === 0) {
+            console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+            return;
+        }
+        console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+        // 遍历用户,发送一轮消息
+        console.log("\n开始发送第一轮消息...");
+        for (const user of activeUsers) {
+            const userLang = getUserLanguage(user);
+            const fcmToken = user.fmToken;
+            // 随机选择一个模板
+            const randomIndex = Math.floor(Math.random() * templates.length);
+            const template = templates[randomIndex];
+            // 从获取的8幅画作中随机选择一个
+            const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+            const artworkId = todaysArtworks[randomArtworkIndex];
+            const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+            await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+        }
+        console.log("消息发送完成。");
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 5 - 1
oms/services/cron-jobs/index.ts

@@ -17,7 +17,11 @@ const settings: [string, string, CronJobModule][] = [
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
   // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
   // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
-  ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
+  // ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
+  ["daily-notify-at-morning", "0 19 * * *", require("./notify/daily-notify-at-morning") as CronJobModule], // 每天下午7点,对应巴西时间早上8点推送一轮
+  ["daily-notify-at-midday", "0 23 * * *", require("./notify/daily-notify-at-midday") as CronJobModule], // 每天下午7点,对应巴西时间早上8点推送一轮
+  ["daily-notify-at-evening", "0 3 * * *", require("./notify/daily-notify-at-evening") as CronJobModule], // 每天凌晨3点,对应巴西时间下午4点
+  ["daily-notify-at-afternoon", "0 7 * * *", require("./notify/daily-notify-at-afternoon") as CronJobModule], // 每天上午7点,对应巴西时间晚上8点
 ];
 
 /**

+ 275 - 0
oms/services/cron-jobs/notify/daily-notify-at-afternoon.ts

@@ -0,0 +1,275 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../../src/models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../../../src/models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+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";
+
+const strategyName = "daily-notify-at-afternoon";
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string, artworkId: string): { [key: string]: string } {
+  // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+  const lang = template.messageContent[userLang] ? userLang : "en";
+
+  // 统一使用 page 路径
+  const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+
+  return {
+    title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+    content: template.messageContent[lang] || template.messageContent["en"] || "",
+    image: imageUrl,
+    bigger: "true",
+    action: "go/art",
+    param: artworkId,
+    extend: template.extend || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM(): Promise<string[]> {
+  try {
+    const artworks = await Art.find({}).sort({ publishTime: -1 }).limit(8).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消息。
+ * 此函数将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (
+  uid: string,
+  cc: string | undefined,
+  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 {
+    // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
+    messageRecord = await MessageRecord.create({
+      uid: uid,
+      cc: cc,
+      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 as any).errorInfo;
+    const isInvalidToken = isTokenInvalidationError(error);
+
+    messageStatus = -1;
+    errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
+
+    if (isInvalidToken) {
+      // 如果是无效令牌错误,清空该用户的 fmToken
+      await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
+      console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+      errorMessage += " (Token cleared)";
+    } else {
+      console.error(`发送消息给用户 ${uid} 失败:`, error);
+    }
+  } finally {
+    // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+    if (messageRecord) {
+      await MessageRecord.findByIdAndUpdate(messageRecord._id, {
+        status: messageStatus,
+        fcmReceipt: fcmReceipt as any,
+        actualSendAt: new Date(),
+        errno: errorMessage,
+      });
+    }
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+    // 筛选条件调整:用户最近12小时不活跃
+    const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+    const activeUsers = await User.find({
+      lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+      fmToken: { $nin: [null, ""] },
+      //   versionName: { $in: ["5.8.0-debug"] },
+      // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      versionCode: { $gte: 347 },
+      // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
+    if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+      console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+      return;
+    }
+    const templates = strategy.templates as IMessageTemplate[];
+    console.log(`找到 ${templates.length} 个消息模板。`);
+
+    // 调整为获取最新的8个画作,用于随机选择
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length === 0) {
+      console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+      return;
+    }
+    console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+
+    // 遍历用户,发送一轮消息
+    console.log("\n开始发送第一轮消息...");
+    for (const user of activeUsers) {
+      const userLang = getUserLanguage(user);
+      const fcmToken = user.fmToken as string;
+
+      // 随机选择一个模板
+      const randomIndex = Math.floor(Math.random() * templates.length);
+      const template = templates[randomIndex];
+
+      // 从获取的8幅画作中随机选择一个
+      const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+      const artworkId = todaysArtworks[randomArtworkIndex];
+
+      const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+
+      await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+    }
+    console.log("消息发送完成。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

+ 275 - 0
oms/services/cron-jobs/notify/daily-notify-at-evening.ts

@@ -0,0 +1,275 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../../src/models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../../../src/models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+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";
+
+const strategyName = "daily-notify-at-evening";
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string, artworkId: string): { [key: string]: string } {
+  // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+  const lang = template.messageContent[userLang] ? userLang : "en";
+
+  // 统一使用 page 路径
+  const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+
+  return {
+    title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+    content: template.messageContent[lang] || template.messageContent["en"] || "",
+    image: imageUrl,
+    bigger: "true",
+    action: "go/art",
+    param: artworkId,
+    extend: template.extend || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM(): Promise<string[]> {
+  try {
+    const artworks = await Art.find({}).sort({ publishTime: -1 }).limit(8).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消息。
+ * 此函数将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (
+  uid: string,
+  cc: string | undefined,
+  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 {
+    // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
+    messageRecord = await MessageRecord.create({
+      uid: uid,
+      cc: cc,
+      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 as any).errorInfo;
+    const isInvalidToken = isTokenInvalidationError(error);
+
+    messageStatus = -1;
+    errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
+
+    if (isInvalidToken) {
+      // 如果是无效令牌错误,清空该用户的 fmToken
+      await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
+      console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+      errorMessage += " (Token cleared)";
+    } else {
+      console.error(`发送消息给用户 ${uid} 失败:`, error);
+    }
+  } finally {
+    // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+    if (messageRecord) {
+      await MessageRecord.findByIdAndUpdate(messageRecord._id, {
+        status: messageStatus,
+        fcmReceipt: fcmReceipt as any,
+        actualSendAt: new Date(),
+        errno: errorMessage,
+      });
+    }
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+    // 筛选条件调整:用户最近12小时不活跃
+    const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+    const activeUsers = await User.find({
+      lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+      fmToken: { $nin: [null, ""] },
+      //   versionName: { $in: ["5.8.0-debug"] },
+      // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      versionCode: { $gte: 347 },
+      // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
+    if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+      console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+      return;
+    }
+    const templates = strategy.templates as IMessageTemplate[];
+    console.log(`找到 ${templates.length} 个消息模板。`);
+
+    // 调整为获取最新的8个画作,用于随机选择
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length === 0) {
+      console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+      return;
+    }
+    console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+
+    // 遍历用户,发送一轮消息
+    console.log("\n开始发送第一轮消息...");
+    for (const user of activeUsers) {
+      const userLang = getUserLanguage(user);
+      const fcmToken = user.fmToken as string;
+
+      // 随机选择一个模板
+      const randomIndex = Math.floor(Math.random() * templates.length);
+      const template = templates[randomIndex];
+
+      // 从获取的8幅画作中随机选择一个
+      const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+      const artworkId = todaysArtworks[randomArtworkIndex];
+
+      const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+
+      await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+    }
+    console.log("消息发送完成。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

+ 275 - 0
oms/services/cron-jobs/notify/daily-notify-at-midday.ts

@@ -0,0 +1,275 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../../src/models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../../../src/models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+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";
+
+const strategyName = "daily-notify-at-midday";
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string, artworkId: string): { [key: string]: string } {
+  // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+  const lang = template.messageContent[userLang] ? userLang : "en";
+
+  // 统一使用 page 路径
+  const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+
+  return {
+    title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+    content: template.messageContent[lang] || template.messageContent["en"] || "",
+    image: imageUrl,
+    bigger: "true",
+    action: "go/art",
+    param: artworkId,
+    extend: template.extend || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM(): Promise<string[]> {
+  try {
+    const artworks = await Art.find({}).sort({ publishTime: -1 }).limit(8).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消息。
+ * 此函数将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (
+  uid: string,
+  cc: string | undefined,
+  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 {
+    // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
+    messageRecord = await MessageRecord.create({
+      uid: uid,
+      cc: cc,
+      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 as any).errorInfo;
+    const isInvalidToken = isTokenInvalidationError(error);
+
+    messageStatus = -1;
+    errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
+
+    if (isInvalidToken) {
+      // 如果是无效令牌错误,清空该用户的 fmToken
+      await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
+      console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+      errorMessage += " (Token cleared)";
+    } else {
+      console.error(`发送消息给用户 ${uid} 失败:`, error);
+    }
+  } finally {
+    // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+    if (messageRecord) {
+      await MessageRecord.findByIdAndUpdate(messageRecord._id, {
+        status: messageStatus,
+        fcmReceipt: fcmReceipt as any,
+        actualSendAt: new Date(),
+        errno: errorMessage,
+      });
+    }
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+    // 筛选条件调整:用户最近12小时不活跃
+    const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+    const activeUsers = await User.find({
+      lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+      fmToken: { $nin: [null, ""] },
+      //   versionName: { $in: ["5.8.0-debug"] },
+      // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      versionCode: { $gte: 347 },
+      // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
+    if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+      console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+      return;
+    }
+    const templates = strategy.templates as IMessageTemplate[];
+    console.log(`找到 ${templates.length} 个消息模板。`);
+
+    // 调整为获取最新的8个画作,用于随机选择
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length === 0) {
+      console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+      return;
+    }
+    console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+
+    // 遍历用户,发送一轮消息
+    console.log("\n开始发送第一轮消息...");
+    for (const user of activeUsers) {
+      const userLang = getUserLanguage(user);
+      const fcmToken = user.fmToken as string;
+
+      // 随机选择一个模板
+      const randomIndex = Math.floor(Math.random() * templates.length);
+      const template = templates[randomIndex];
+
+      // 从获取的8幅画作中随机选择一个
+      const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+      const artworkId = todaysArtworks[randomArtworkIndex];
+
+      const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+
+      await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+    }
+    console.log("消息发送完成。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

+ 275 - 0
oms/services/cron-jobs/notify/daily-notify-at-morning.ts

@@ -0,0 +1,275 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../../src/models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../../../src/models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+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";
+
+const strategyName = "daily-notify-at-morning";
+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 用户的语言代码
+ * @param artworkId 画作ID
+ */
+function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string, artworkId: string): { [key: string]: string } {
+  // 优先使用用户的语言,如果没有,则回退到默认语言 'en'
+  const lang = template.messageContent[userLang] ? userLang : "en";
+
+  // 统一使用 page 路径
+  const imageUrl = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artworkId}.png`;
+
+  return {
+    title: template.messageTitle[lang] || template.messageTitle["en"] || "",
+    content: template.messageContent[lang] || template.messageContent["en"] || "",
+    image: imageUrl,
+    bigger: "true",
+    action: "go/art",
+    param: artworkId,
+    extend: template.extend || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的8幅最新画作。
+ * 查询 art 表,获取最新的8个作品。
+ * @returns 包含八个画作 ID 的数组
+ */
+async function getTodaysArtworksForFCM(): Promise<string[]> {
+  try {
+    const artworks = await Art.find({}).sort({ publishTime: -1 }).limit(8).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消息。
+ * 此函数将首先创建数据库记录,然后使用该记录的 _id 作为 msgid 进行发送。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = async (
+  uid: string,
+  cc: string | undefined,
+  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 {
+    // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
+    messageRecord = await MessageRecord.create({
+      uid: uid,
+      cc: cc,
+      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 as any).errorInfo;
+    const isInvalidToken = isTokenInvalidationError(error);
+
+    messageStatus = -1;
+    errorMessage = errorInfo ? errorInfo.code : (error as Error).message;
+
+    if (isInvalidToken) {
+      // 如果是无效令牌错误,清空该用户的 fmToken
+      await User.findOneAndUpdate({ uid: uid }, { fmToken: null });
+      console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+      errorMessage += " (Token cleared)";
+    } else {
+      console.error(`发送消息给用户 ${uid} 失败:`, error);
+    }
+  } finally {
+    // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+    if (messageRecord) {
+      await MessageRecord.findByIdAndUpdate(messageRecord._id, {
+        status: messageStatus,
+        fcmReceipt: fcmReceipt as any,
+        actualSendAt: new Date(),
+        errno: errorMessage,
+      });
+    }
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+    // 筛选条件调整:用户最近12小时不活跃
+    const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+    const activeUsers = await User.find({
+      lastActiveAt: { $gte: sevenDaysAgo, $lt: twelveHoursAgo }, // 调整筛选条件
+      fmToken: { $nin: [null, ""] },
+      //   versionName: { $in: ["5.8.0-debug"] },
+      // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      versionCode: { $gte: 347 },
+      // $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
+    if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+      console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板。`);
+      return;
+    }
+    const templates = strategy.templates as IMessageTemplate[];
+    console.log(`找到 ${templates.length} 个消息模板。`);
+
+    // 调整为获取最新的8个画作,用于随机选择
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length === 0) {
+      console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+      return;
+    }
+    console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+
+    // 遍历用户,发送一轮消息
+    console.log("\n开始发送第一轮消息...");
+    for (const user of activeUsers) {
+      const userLang = getUserLanguage(user);
+      const fcmToken = user.fmToken as string;
+
+      // 随机选择一个模板
+      const randomIndex = Math.floor(Math.random() * templates.length);
+      const template = templates[randomIndex];
+
+      // 从获取的8幅画作中随机选择一个
+      const randomArtworkIndex = Math.floor(Math.random() * todaysArtworks.length);
+      const artworkId = todaysArtworks[randomArtworkIndex];
+
+      const messageData = getMessageDataFromTemplate(template, userLang, artworkId);
+
+      await sendAndRecordMessage(user.uid, user.cc, fcmToken, template, messageData, strategy._id, strategy.name);
+    }
+    console.log("消息发送完成。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}