Prechádzať zdrojové kódy

fcm消息推送新增即时推送的和计划推送

guoziyun 9 mesiacov pred
rodič
commit
561e6c8b62

+ 2 - 7
oms/dist/services/cron-jobs/active-user-daily-notify.js

@@ -17,7 +17,7 @@ const fcmService_1 = require("../../src/services/fcmService");
 const artModel_1 = __importDefault(require("../../src/models/artModel"));
 const strategyName = "active_new_content_notify";
 const fcmService = fcmService_1.FCMService.getInstance();
-// 国家代码到语言的映射表,包含您提供的 top10 国家,可根据需求扩展
+// 国家代码到语言的映射表
 const countryCodeToLanguageMap = {
     CN: "zh-cn",
     US: "en",
@@ -76,12 +76,7 @@ function getMessageDataFromTemplate(template, userLang) {
  */
 async function getTodaysArtworksForFCM() {
     try {
-        const artworks = await artModel_1.default.find({ tags: "fcm" })
-            // const artworks = await Art.find({})
-            .sort({ publishTime: -1 }) // 倒序排序
-            .limit(2) // 限制为2个
-            .lean();
-        // 如果找到,返回 ID 数组;如果不足2个,则返回找到的所有 ID
+        const artworks = await artModel_1.default.find({ tags: "fcm" }).sort({ publishTime: -1 }).limit(2).lean();
         return artworks.map((art) => art._id.toString());
     }
     catch (error) {

+ 308 - 0
oms/dist/services/cron-jobs/fcm-notify.js

@@ -0,0 +1,308 @@
+"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");
+const messageTemplateModel_1 = require("../../src/models/messageTemplateModel");
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+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 lodash_1 = require("lodash"); // 使用 lodash 的 shuffle 函数来打乱数组
+// 定义两个不同的策略名称
+const immediateStrategyName = "active_new_content_notify";
+const scheduledStrategyName = "notify-by-last-active";
+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 用户的语言代码
+ */
+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 || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的1幅画作。
+ * 查询 art 表,查找 tags 标签等于 'fcm' 的记录,按 publishTime 倒序排,取头部的1个 art。
+ * @returns 包含一个画作 ID 的数组 [art_id1]
+ */
+async function getTodaysArtworksForFCM() {
+    try {
+        const artworks = await artModel_1.default.find({ tags: "fcm" }).sort({ publishTime: -1 }).limit(1).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 API发送。
+ * @param uid 用户ID
+ * @param fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendImmediateMessageAndRecord = async (uid, fcmToken, template, messageData, strategyId, strategyName) => {
+    let messageRecord = null;
+    let fcmReceipt = null;
+    let messageStatus = 0;
+    let errorMessage = null;
+    try {
+        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,
+        });
+        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.errorInfo;
+        const isInvalidToken = isTokenInvalidationError(error);
+        messageStatus = -1;
+        errorMessage = errorInfo ? errorInfo.code : error.message;
+        if (isInvalidToken) {
+            await userModel_1.User.findOneAndUpdate({ uid: uid }, { fmToken: null });
+            console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+            errorMessage += " (Token cleared)";
+        }
+        else {
+            console.error(`发送消息给用户 ${uid} 失败:`, error);
+        }
+    }
+    finally {
+        if (messageRecord) {
+            await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
+                status: messageStatus,
+                fcmReceipt: fcmReceipt,
+                actualSendAt: new Date(),
+                errno: errorMessage,
+            });
+        }
+    }
+};
+/**
+ * 创建定时消息记录。
+ * 此函数只负责创建数据库记录,不进行FCM发送。
+ * @param user 用户对象
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ * @param plannedSendAt 计划发送时间
+ */
+const createScheduledMessageRecord = async (user, template, messageData, strategyId, strategyName, plannedSendAt) => {
+    try {
+        await messageRecordModel_1.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,当所有任务完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        // 1. 获取所有需要的策略和模板
+        const immediateStrategy = await messageStrategyModel_1.MessageStrategy.findOne({ name: immediateStrategyName }).populate("templates");
+        const scheduledStrategy = await messageStrategyModel_1.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;
+        const scheduledTemplates = scheduledStrategy.templates;
+        // 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 userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo },
+            fmToken: { $nin: [null, ""] },
+            $or: [{ versionCode: { $gte: 347 } }, { versionCode: -1 }],
+        })
+            .select("_id uid fmToken lang cc lastActiveAt")
+            .lean();
+        if (activeUsers.length === 0) {
+            console.log("未找到符合条件的用户,脚本结束。");
+            return;
+        }
+        console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+        // 随机打乱用户列表以进行 A/B 测试
+        const shuffledUsers = (0, lodash_1.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;
+            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);
+    });
+}

+ 9 - 0
oms/package-lock.json

@@ -19,6 +19,7 @@
         "dotenv": "^17.2.1",
         "express": "^5.1.0",
         "firebase-admin": "^13.5.0",
+        "lodash": "^4.17.21",
         "moment": "^2.30.1",
         "mongodb": "^5.0.0",
         "mongoose": "^7.0.0",
@@ -34,6 +35,7 @@
         "@types/amqplib": "^0.10.7",
         "@types/dotenv": "^6.1.1",
         "@types/express": "^5.0.3",
+        "@types/lodash": "^4.17.20",
         "@types/moment": "^2.11.29",
         "@types/mongodb": "^4.0.6",
         "@types/mongoose": "^5.11.96",
@@ -822,6 +824,13 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/lodash": {
+      "version": "4.17.20",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
+      "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/long": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",

+ 2 - 0
oms/package.json

@@ -28,6 +28,7 @@
     "dotenv": "^17.2.1",
     "express": "^5.1.0",
     "firebase-admin": "^13.5.0",
+    "lodash": "^4.17.21",
     "moment": "^2.30.1",
     "mongodb": "^5.0.0",
     "mongoose": "^7.0.0",
@@ -43,6 +44,7 @@
     "@types/amqplib": "^0.10.7",
     "@types/dotenv": "^6.1.1",
     "@types/express": "^5.0.3",
+    "@types/lodash": "^4.17.20",
     "@types/moment": "^2.11.29",
     "@types/mongodb": "^4.0.6",
     "@types/mongoose": "^5.11.96",

+ 2 - 7
oms/services/cron-jobs/active-user-daily-notify.ts

@@ -16,7 +16,7 @@ import Art, { IArt } from "../../src/models/artModel";
 const strategyName = "active_new_content_notify";
 const fcmService = FCMService.getInstance();
 
-// 国家代码到语言的映射表,包含您提供的 top10 国家,可根据需求扩展
+// 国家代码到语言的映射表
 const countryCodeToLanguageMap: { [key: string]: string } = {
   CN: "zh-cn",
   US: "en",
@@ -79,13 +79,8 @@ function getMessageDataFromTemplate(template: IMessageTemplate, userLang: string
  */
 async function getTodaysArtworksForFCM(): Promise<string[]> {
   try {
-    const artworks = await Art.find({ tags: "fcm" })
-      // const artworks = await Art.find({})
-      .sort({ publishTime: -1 }) // 倒序排序
-      .limit(2) // 限制为2个
-      .lean<IArt[]>();
+    const artworks = await Art.find({ tags: "fcm" }).sort({ publishTime: -1 }).limit(2).lean<IArt[]>();
 
-    // 如果找到,返回 ID 数组;如果不足2个,则返回找到的所有 ID
     return artworks.map((art) => art._id.toString());
   } catch (error) {
     console.error("查询今日画作失败:", error);

+ 347 - 0
oms/services/cron-jobs/fcm-notify.ts

@@ -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);
+    });
+}