Forráskód Böngészése

按当地时区推送

guoziyun 9 hónapja
szülő
commit
4f42bf5c48

+ 202 - 0
oms/dist/services/cron-jobs/notify/local-timezone-notify.js

@@ -0,0 +1,202 @@
+"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");
+const messageStrategyModel_1 = require("../../../src/models/messageStrategyModel");
+const artModel_1 = __importDefault(require("../../../src/models/artModel"));
+const timezoneService_1 = require("../../../src/services/timezoneService");
+// 确保模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+mongoose_1.default.model("MessageStrategy", messageStrategyModel_1.MessageStrategy.schema);
+// 国家代码到语言的映射表
+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",
+};
+// 新增:每日推送策略的配置
+const dailyStrategies = [
+    { name: "local-morning-notify", hour: 8 }, // 早上8点
+    { name: "local-midday-notify", hour: 12 }, // 中午12点
+    { name: "local-evening-notify", hour: 20 }, // 晚上8点
+];
+/**
+ * 根据用户的 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) {
+    const lang = template.messageContent[userLang] ? userLang : "en";
+    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: template.image || imageUrl, // 如果template指定了图片则用template的,没有则使用随机分配的今日新图
+        bigger: "true",
+        action: "go/art",
+        param: template.param || artworkId,
+        extend: template.extend || "",
+    };
+}
+/**
+ * 获取今日的用于消息推送的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 [];
+    }
+}
+/**
+ * 记录消息,不再立即发送。
+ * 此函数仅创建数据库记录。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ * @param plannedSendAt 计划发送时间
+ */
+const recordMessage = async (uid, cc, template, messageData, strategyId, strategyName, plannedSendAt) => {
+    try {
+        // 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: plannedSendAt, // 使用计算出的计划时间
+        //   status: 0,
+        // });
+        console.log(`成功为用户 ${uid} 创建消息记录。计划发送时间: ${plannedSendAt.toISOString()}`);
+    }
+    catch (error) {
+        console.error(`创建消息记录失败,用户 ${uid}:`, error);
+    }
+};
+/**
+ * 脚本的入口方法,用于筛选用户并创建每日三条消息的推送任务。
+ * @returns {Promise<void>}
+ */
+async function run() {
+    console.log("脚本开始:创建活跃用户每日通知任务...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        const activeUsers = await userModel_1.User.find({
+            lastActiveAt: { $gte: sevenDaysAgo },
+            fmToken: { $nin: [null, ""] },
+            versionCode: { $gte: 347 },
+        })
+            .select("_id uid fmToken lang cc")
+            .lean();
+        if (activeUsers.length === 0) {
+            console.log("未找到符合条件的用户,脚本结束。");
+            return;
+        }
+        console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length === 0) {
+            console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+            return;
+        }
+        console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+        // 加载所有需要的策略和模板
+        const strategies = await messageStrategyModel_1.MessageStrategy.find({ name: { $in: dailyStrategies.map((s) => s.name) } }).populate("templates");
+        if (strategies.length !== dailyStrategies.length) {
+            console.error("未找到所有必需的消息策略。");
+            return;
+        }
+        const strategyMap = new Map(strategies.map((s) => [s.name, s]));
+        // 实例化 TimezoneService
+        const timezoneService = timezoneService_1.TimezoneService.getInstance();
+        // 遍历用户,创建每日三条消息任务
+        console.log("\n开始为用户创建每日任务...");
+        for (const user of activeUsers) {
+            const userLang = getUserLanguage(user);
+            // 为每个预设的时间点创建一条消息记录
+            for (const config of dailyStrategies) {
+                const strategy = strategyMap.get(config.name);
+                if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+                    console.error(`策略 '${config.name}' 或其绑定的模板缺失,跳过此任务。`);
+                    continue;
+                }
+                const templates = strategy.templates;
+                // 随机选择一个模板
+                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);
+                // **核心改动:直接调用 TimezoneService 中的方法**
+                const plannedSendAt = timezoneService.getPlannedSendTime(user.cc, config.hour);
+                await recordMessage(user.uid, user.cc, template, messageData, strategy._id, strategy.name, plannedSendAt);
+            }
+        }
+        console.log("所有每日任务创建完成。");
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 确保只有在直接运行时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 146 - 0
oms/dist/src/services/timezoneService.js

@@ -0,0 +1,146 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.TimezoneService = void 0;
+const luxon_1 = require("luxon");
+const ct = __importStar(require("countries-and-timezones"));
+// 自定义默认时区映射表
+const defaultTimezoneMap = {
+    BR: "America/Sao_Paulo", // 巴西(圣保罗)
+    RU: "Europe/Moscow", // 俄罗斯(莫斯科)
+    US: "America/New_York", // 美国(纽约,代表东部时区)
+    IN: "Asia/Kolkata", // 印度(加尔各答)
+    MX: "America/Mexico_City", // 墨西哥(墨西哥城)
+    AR: "America/Argentina/Buenos_Aires", // 阿根廷(布宜诺斯艾利斯)
+    ID: "Asia/Jakarta", // 印尼(雅加达)
+    ES: "Europe/Madrid", // 西班牙(马德里)
+    CL: "America/Santiago", // 智利(圣地亚哥)
+    CO: "America/Bogota", // 哥伦比亚(波哥大)
+    FR: "Europe/Paris", // 法国(巴黎)
+    IT: "Europe/Rome", // 意大利(罗马)
+    PL: "Europe/Warsaw", // 波兰(华沙)
+    DE: "Europe/Berlin", // 德国(柏林)
+    RO: "Europe/Bucharest", // 罗马尼亚(布加勒斯特)
+    GB: "Europe/London", // 英国(伦敦)
+    UA: "Europe/Kyiv", // 乌克兰(基辅)
+    TR: "Europe/Istanbul", // 土耳其(伊斯坦布尔)
+    JP: "Asia/Tokyo", // 日本(东京)
+    PH: "Asia/Manila", // 菲律宾(马尼拉)
+    KR: "Asia/Seoul", // 韩国(首尔)
+    CA: "America/Toronto", // 加拿大(多伦多,代表东部时区)
+    IR: "Asia/Tehran", // 伊朗(德黑兰)
+    TH: "Asia/Bangkok", // 泰国(曼谷)
+    CR: "America/Costa_Rica", // 哥斯达黎加(圣何塞)
+    ZA: "Africa/Johannesburg", // 南非(约翰内斯堡)
+    VE: "America/Caracas", // 委内瑞拉(加拉加斯)
+    AU: "Australia/Sydney", // 澳大利亚(悉尼,代表东部时区)
+    PK: "Asia/Karachi", // 巴基斯坦(卡拉奇)
+    VN: "Asia/Ho_Chi_Minh", // 越南(胡志明市)
+    HU: "Europe/Budapest", // 匈牙利(布达佩斯)
+    PT: "Europe/Lisbon", // 葡萄牙(里斯本)
+    EG: "Africa/Cairo", // 埃及(开罗)
+    CN: "Asia/Shanghai", // 中国(上海)
+};
+/**
+ * 这是一个处理时区转换的单例服务。
+ */
+class TimezoneService {
+    constructor() { }
+    static getInstance() {
+        if (!TimezoneService.instance) {
+            TimezoneService.instance = new TimezoneService();
+        }
+        return TimezoneService.instance;
+    }
+    /**
+     * 根据国家代码获取对应的时区。
+     * 优先从自定义映射表中查找,如果找不到,则退回到 countries-and-timezones 库的第一个时区。
+     * 如果仍找不到,默认返回 'UTC'。
+     * @param countryCode 国家代码(如 'US', 'CN')
+     * @returns 时区字符串(如 'Asia/Shanghai')
+     */
+    getTimezoneByCountryCode(countryCode) {
+        const uppercaseCc = countryCode.toUpperCase();
+        // 1. 优先从自定义映射表中查找
+        if (defaultTimezoneMap[uppercaseCc]) {
+            return defaultTimezoneMap[uppercaseCc];
+        }
+        // 2. 如果自定义映射表中没有,则退回到 countries-and-timezones 库
+        const country = ct.getCountry(uppercaseCc);
+        if (country && country.timezones && country.timezones.length > 0) {
+            return country.timezones[0];
+        }
+        // 3. 最终默认值
+        return "UTC";
+    }
+    getNowInTimezone(timezone) {
+        return luxon_1.DateTime.now().setZone(timezone);
+    }
+    getUTCFromTimezoneDate(localDateTime, timezone) {
+        const zonedTime = localDateTime.setZone(timezone, { keepLocalTime: true });
+        return zonedTime.toJSDate();
+    }
+    getPlannedSendTime(cc, targetHour) {
+        const now = luxon_1.DateTime.now().toUTC();
+        const timezone = this.getTimezoneByCountryCode(cc || "US");
+        const plannedTime = now.setZone(timezone).set({ hour: targetHour, minute: 0, second: 0, millisecond: 0 });
+        let plannedSendAt = plannedTime.toUTC();
+        if (plannedSendAt < now) {
+            plannedSendAt = plannedSendAt.plus({ days: 1 });
+        }
+        return plannedSendAt.toJSDate();
+    }
+}
+exports.TimezoneService = TimezoneService;
+// ---
+// 示例用法(非代码库一部分,仅供测试)
+if (require.main === module) {
+    const timezoneService = TimezoneService.getInstance();
+    const userCountryCode = "RU";
+    const targetHour = 8; // 巴西当地早上8点
+    // 验证 getPlannedSendTime 方法
+    const plannedTimeUTC = timezoneService.getPlannedSendTime(userCountryCode, targetHour);
+    const plannedTimeUTCISO = plannedTimeUTC.toISOString();
+    // 获取北京时间,用于对比
+    const beijingTimezone = timezoneService.getTimezoneByCountryCode("CN");
+    const beijingTime = luxon_1.DateTime.fromJSDate(plannedTimeUTC, { zone: beijingTimezone });
+    console.log(`--- 计算结果 ---`);
+    console.log(`用户国家:${userCountryCode}`);
+    console.log(`目标时区:${timezoneService.getTimezoneByCountryCode(userCountryCode)}`);
+    console.log(`目标当地时间:${targetHour}:00:00`);
+    console.log(`计算出的UTC计划发送时间:${plannedTimeUTCISO}`);
+    console.log(`对应的北京时间:${beijingTime.toLocaleString(luxon_1.DateTime.DATETIME_FULL)}`);
+    console.log(`对应的北京时间(ISO):${beijingTime.toISO()}`);
+}

+ 39 - 0
oms/package-lock.json

@@ -11,15 +11,18 @@
       "dependencies": {
         "@clickhouse/client": "^1.12.1",
         "@types/bcryptjs": "^2.4.6",
+        "@types/luxon": "^3.7.1",
         "amqplib": "^0.10.8",
         "axios": "^1.11.0",
         "bcryptjs": "^3.0.2",
+        "countries-and-timezones": "^3.8.0",
         "date-fns": "^4.1.0",
         "dayjs": "^1.11.13",
         "dotenv": "^17.2.1",
         "express": "^5.1.0",
         "firebase-admin": "^13.5.0",
         "lodash": "^4.17.21",
+        "luxon": "^3.7.2",
         "moment": "^2.30.1",
         "mongodb": "^5.0.0",
         "mongoose": "^7.0.0",
@@ -33,6 +36,7 @@
       },
       "devDependencies": {
         "@types/amqplib": "^0.10.7",
+        "@types/countries-and-timezones": "^3.2.2",
         "@types/dotenv": "^6.1.1",
         "@types/express": "^5.0.3",
         "@types/lodash": "^4.17.20",
@@ -773,6 +777,16 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/countries-and-timezones": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/countries-and-timezones/-/countries-and-timezones-3.2.2.tgz",
+      "integrity": "sha512-D5279Q80+41KzOqfUX+sLorACjPE7PeNcJs/E5mnKfARCxKuSnXIeIkr2GcqmgJdZqGrtXBS19dIi9WMan9ZfQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "countries-and-timezones": "*"
+      }
+    },
     "node_modules/@types/dotenv": {
       "version": "6.1.1",
       "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz",
@@ -838,6 +852,12 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/@types/luxon": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
+      "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
+      "license": "MIT"
+    },
     "node_modules/@types/mime": {
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -1588,6 +1608,16 @@
         "node": ">=6.6.0"
       }
     },
+    "node_modules/countries-and-timezones": {
+      "version": "3.8.0",
+      "resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.8.0.tgz",
+      "integrity": "sha512-+Ze9h5f4dQpUwbzTm0DEkiPiZyim9VHV4/mSnT4zNYJnrnfwsKjAZPtnp7J5VzejCDgySs+2SSc6MDdCnD43GA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.x",
+        "npm": ">=5.x"
+      }
+    },
     "node_modules/create-require": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -2947,6 +2977,15 @@
         "lru-cache": "6.0.0"
       }
     },
+    "node_modules/luxon": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+      "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/make-error": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",

+ 4 - 0
oms/package.json

@@ -20,15 +20,18 @@
   "dependencies": {
     "@clickhouse/client": "^1.12.1",
     "@types/bcryptjs": "^2.4.6",
+    "@types/luxon": "^3.7.1",
     "amqplib": "^0.10.8",
     "axios": "^1.11.0",
     "bcryptjs": "^3.0.2",
+    "countries-and-timezones": "^3.8.0",
     "date-fns": "^4.1.0",
     "dayjs": "^1.11.13",
     "dotenv": "^17.2.1",
     "express": "^5.1.0",
     "firebase-admin": "^13.5.0",
     "lodash": "^4.17.21",
+    "luxon": "^3.7.2",
     "moment": "^2.30.1",
     "mongodb": "^5.0.0",
     "mongoose": "^7.0.0",
@@ -42,6 +45,7 @@
   },
   "devDependencies": {
     "@types/amqplib": "^0.10.7",
+    "@types/countries-and-timezones": "^3.2.2",
     "@types/dotenv": "^6.1.1",
     "@types/express": "^5.0.3",
     "@types/lodash": "^4.17.20",

+ 228 - 0
oms/services/cron-jobs/notify/local-timezone-notify.ts

@@ -0,0 +1,228 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../../src/models/userModel";
+import { IMessageTemplate, MessageTemplate } from "../../../src/models/messageTemplateModel";
+import { MessageStrategy, IMessageStrategy } from "../../../src/models/messageStrategyModel";
+import { MessageRecord, IMessageRecord } from "../../../src/models/messageRecordModel";
+import Art, { IArt } from "../../../src/models/artModel";
+import { TimezoneService } from "../../../src/services/timezoneService";
+
+// 确保模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+mongoose.model("MessageStrategy", MessageStrategy.schema);
+
+// 国家代码到语言的映射表
+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",
+};
+
+// 新增:每日推送策略的配置
+const dailyStrategies = [
+  { name: "local-morning-notify", hour: 8 }, // 早上8点
+  { name: "local-midday-notify", hour: 12 }, // 中午12点
+  { name: "local-evening-notify", hour: 20 }, // 晚上8点
+];
+
+/**
+ * 根据用户的 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 } {
+  const lang = template.messageContent[userLang] ? userLang : "en";
+  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: template.image || imageUrl, // 如果template指定了图片则用template的,没有则使用随机分配的今日新图
+    bigger: "true",
+    action: "go/art",
+    param: template.param || artworkId,
+    extend: template.extend || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的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 [];
+  }
+}
+
+/**
+ * 记录消息,不再立即发送。
+ * 此函数仅创建数据库记录。
+ * @param uid 用户ID
+ * @param cc 用户国家代码
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ * @param plannedSendAt 计划发送时间
+ */
+const recordMessage = async (
+  uid: string,
+  cc: string | undefined,
+  template: IMessageTemplate,
+  messageData: { [key: string]: string },
+  strategyId: mongoose.Types.ObjectId,
+  strategyName: string,
+  plannedSendAt: Date
+) => {
+  try {
+    // 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: plannedSendAt, // 使用计算出的计划时间
+    //   status: 0,
+    // });
+
+    console.log(`成功为用户 ${uid} 创建消息记录。计划发送时间: ${plannedSendAt.toISOString()}`);
+  } catch (error) {
+    console.error(`创建消息记录失败,用户 ${uid}:`, error);
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并创建每日三条消息的推送任务。
+ * @returns {Promise<void>}
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:创建活跃用户每日通知任务...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+    const activeUsers = await User.find({
+      lastActiveAt: { $gte: sevenDaysAgo },
+      fmToken: { $nin: [null, ""] },
+      versionCode: { $gte: 347 },
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length === 0) {
+      console.warn("今日用于FCM消息推送的画作数量不足。脚本结束。");
+      return;
+    }
+    console.log(`今日获取到 ${todaysArtworks.length} 幅最新画作。`);
+
+    // 加载所有需要的策略和模板
+    const strategies = await MessageStrategy.find({ name: { $in: dailyStrategies.map((s) => s.name) } }).populate("templates");
+    if (strategies.length !== dailyStrategies.length) {
+      console.error("未找到所有必需的消息策略。");
+      return;
+    }
+
+    const strategyMap = new Map(strategies.map((s) => [s.name, s]));
+
+    // 实例化 TimezoneService
+    const timezoneService = TimezoneService.getInstance();
+
+    // 遍历用户,创建每日三条消息任务
+    console.log("\n开始为用户创建每日任务...");
+    for (const user of activeUsers) {
+      const userLang = getUserLanguage(user);
+
+      // 为每个预设的时间点创建一条消息记录
+      for (const config of dailyStrategies) {
+        const strategy = strategyMap.get(config.name);
+        if (!strategy || !strategy.templates || strategy.templates.length === 0) {
+          console.error(`策略 '${config.name}' 或其绑定的模板缺失,跳过此任务。`);
+          continue;
+        }
+        const templates = strategy.templates as IMessageTemplate[];
+
+        // 随机选择一个模板
+        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);
+
+        // **核心改动:直接调用 TimezoneService 中的方法**
+        const plannedSendAt = timezoneService.getPlannedSendTime(user.cc, config.hour);
+
+        await recordMessage(user.uid, user.cc, template, messageData, strategy._id, strategy.name, plannedSendAt);
+      }
+    }
+
+    console.log("所有每日任务创建完成。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 确保只有在直接运行时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

+ 130 - 0
oms/src/services/timezoneService.ts

@@ -0,0 +1,130 @@
+import { DateTime } from "luxon";
+import * as ct from "countries-and-timezones";
+
+// 自定义默认时区映射表
+const defaultTimezoneMap: { [key: string]: string } = {
+  BR: "America/Sao_Paulo", // 巴西(圣保罗)
+  RU: "Europe/Moscow", // 俄罗斯(莫斯科)
+  US: "America/New_York", // 美国(纽约,代表东部时区)
+  IN: "Asia/Kolkata", // 印度(加尔各答)
+  MX: "America/Mexico_City", // 墨西哥(墨西哥城)
+  AR: "America/Argentina/Buenos_Aires", // 阿根廷(布宜诺斯艾利斯)
+  ID: "Asia/Jakarta", // 印尼(雅加达)
+  ES: "Europe/Madrid", // 西班牙(马德里)
+  CL: "America/Santiago", // 智利(圣地亚哥)
+  CO: "America/Bogota", // 哥伦比亚(波哥大)
+  FR: "Europe/Paris", // 法国(巴黎)
+  IT: "Europe/Rome", // 意大利(罗马)
+  PL: "Europe/Warsaw", // 波兰(华沙)
+  DE: "Europe/Berlin", // 德国(柏林)
+  RO: "Europe/Bucharest", // 罗马尼亚(布加勒斯特)
+  GB: "Europe/London", // 英国(伦敦)
+  UA: "Europe/Kyiv", // 乌克兰(基辅)
+  TR: "Europe/Istanbul", // 土耳其(伊斯坦布尔)
+  JP: "Asia/Tokyo", // 日本(东京)
+  PH: "Asia/Manila", // 菲律宾(马尼拉)
+  KR: "Asia/Seoul", // 韩国(首尔)
+  CA: "America/Toronto", // 加拿大(多伦多,代表东部时区)
+  IR: "Asia/Tehran", // 伊朗(德黑兰)
+  TH: "Asia/Bangkok", // 泰国(曼谷)
+  CR: "America/Costa_Rica", // 哥斯达黎加(圣何塞)
+  ZA: "Africa/Johannesburg", // 南非(约翰内斯堡)
+  VE: "America/Caracas", // 委内瑞拉(加拉加斯)
+  AU: "Australia/Sydney", // 澳大利亚(悉尼,代表东部时区)
+  PK: "Asia/Karachi", // 巴基斯坦(卡拉奇)
+  VN: "Asia/Ho_Chi_Minh", // 越南(胡志明市)
+  HU: "Europe/Budapest", // 匈牙利(布达佩斯)
+  PT: "Europe/Lisbon", // 葡萄牙(里斯本)
+  EG: "Africa/Cairo", // 埃及(开罗)
+  CN: "Asia/Shanghai", // 中国(上海)
+};
+
+/**
+ * 这是一个处理时区转换的单例服务。
+ */
+export class TimezoneService {
+  private static instance: TimezoneService;
+
+  private constructor() {}
+
+  public static getInstance(): TimezoneService {
+    if (!TimezoneService.instance) {
+      TimezoneService.instance = new TimezoneService();
+    }
+    return TimezoneService.instance;
+  }
+
+  /**
+   * 根据国家代码获取对应的时区。
+   * 优先从自定义映射表中查找,如果找不到,则退回到 countries-and-timezones 库的第一个时区。
+   * 如果仍找不到,默认返回 'UTC'。
+   * @param countryCode 国家代码(如 'US', 'CN')
+   * @returns 时区字符串(如 'Asia/Shanghai')
+   */
+  public getTimezoneByCountryCode(countryCode: string): string {
+    const uppercaseCc = countryCode.toUpperCase();
+
+    // 1. 优先从自定义映射表中查找
+    if (defaultTimezoneMap[uppercaseCc]) {
+      return defaultTimezoneMap[uppercaseCc];
+    }
+
+    // 2. 如果自定义映射表中没有,则退回到 countries-and-timezones 库
+    const country = ct.getCountry(uppercaseCc);
+    if (country && country.timezones && country.timezones.length > 0) {
+      return country.timezones[0];
+    }
+
+    // 3. 最终默认值
+    return "UTC";
+  }
+
+  public getNowInTimezone(timezone: string): DateTime {
+    return DateTime.now().setZone(timezone);
+  }
+
+  public getUTCFromTimezoneDate(localDateTime: DateTime, timezone: string): Date {
+    const zonedTime = localDateTime.setZone(timezone, { keepLocalTime: true });
+    return zonedTime.toJSDate();
+  }
+
+  public getPlannedSendTime(cc: string | undefined, targetHour: number): Date {
+    const now = DateTime.now().toUTC();
+    const timezone = this.getTimezoneByCountryCode(cc || "US");
+
+    const plannedTime = now.setZone(timezone).set({ hour: targetHour, minute: 0, second: 0, millisecond: 0 });
+
+    let plannedSendAt = plannedTime.toUTC();
+
+    if (plannedSendAt < now) {
+      plannedSendAt = plannedSendAt.plus({ days: 1 });
+    }
+
+    return plannedSendAt.toJSDate();
+  }
+}
+
+// ---
+
+// 示例用法(非代码库一部分,仅供测试)
+if (require.main === module) {
+  const timezoneService = TimezoneService.getInstance();
+  const userCountryCode = "RU";
+  const targetHour = 8; // 巴西当地早上8点
+
+  // 验证 getPlannedSendTime 方法
+  const plannedTimeUTC = timezoneService.getPlannedSendTime(userCountryCode, targetHour);
+  const plannedTimeUTCISO = plannedTimeUTC.toISOString();
+
+  // 获取北京时间,用于对比
+  const beijingTimezone = timezoneService.getTimezoneByCountryCode("CN");
+  const beijingTime = DateTime.fromJSDate(plannedTimeUTC, { zone: beijingTimezone });
+
+  console.log(`--- 计算结果 ---`);
+  console.log(`用户国家:${userCountryCode}`);
+  console.log(`目标时区:${timezoneService.getTimezoneByCountryCode(userCountryCode)}`);
+  console.log(`目标当地时间:${targetHour}:00:00`);
+  console.log(`计算出的UTC计划发送时间:${plannedTimeUTCISO}`);
+  console.log(`对应的北京时间:${beijingTime.toLocaleString(DateTime.DATETIME_FULL)}`);
+  console.log(`对应的北京时间(ISO):${beijingTime.toISO()}`);
+}