Ver código fonte

增加给活跃用户的每日新作品推送脚本

guoziyun 9 meses atrás
pai
commit
69baf5e77e

+ 23 - 1
.vscode/launch.json

@@ -5,8 +5,15 @@
       "type": "chrome",
       "request": "launch",
       "name": "omsapp",
+      // 启动URL必须包含baseHref
       "url": "http://localhost:4200/app",
-      "webRoot": "${workspaceFolder}/omsapp"
+      // webRoot指向本地项目根目录
+      "webRoot": "${workspaceFolder}/omsapp",
+      "sourceMapPathOverrides": {
+        // 这是最关键的行:
+        // 将浏览器URL的 `/app/` 路径,映射到你本地文件系统的根目录。
+        "/app/*": "${workspaceFolder}/omsapp/*"
+      }
     },
     {
       "type": "node",
@@ -70,6 +77,21 @@
       "cwd": "${workspaceFolder}/oms", // Set the current working directory to the 'oms' folder
       "console": "integratedTerminal", // Or "internalConsole"
       "internalConsoleOptions": "openOnSessionStart"
+    },
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "active-user-daily-notify (TS-Node)",
+      "skipFiles": ["<node_internals>/**"],
+      "program": "${workspaceFolder}/oms/src/scripts/active-user-daily-notify.ts.ts",
+      "runtimeArgs": [
+        "--require",
+        "ts-node/register" // This tells Node.js to use ts-node to register a TypeScript transpiler
+      ],
+      "args": [], // Optional arguments for your script
+      "cwd": "${workspaceFolder}/oms", // Set the current working directory to the 'oms' folder
+      "console": "integratedTerminal", // Or "internalConsole"
+      "internalConsoleOptions": "openOnSessionStart"
     }
   ]
 }

+ 1 - 30
oms/dist/src/controllers/messageRecordController.js

@@ -25,7 +25,7 @@ class MessageRecordController {
      * @access Private
      */
     async getPaginatedRecords(req, res) {
-        const { page = 1, limit = 30, uid, activityId, status, startDate, endDate } = req.query;
+        const { page = 1, limit = 30, uid, activityName, templateName, strategyName, status, startDate, endDate } = req.query;
         const pageNum = parseInt(page, 10);
         const limitNum = parseInt(limit, 10);
         // 动态构建查询过滤器
@@ -33,13 +33,6 @@ class MessageRecordController {
         if (uid) {
             filters.uid = uid;
         }
-        if (activityId) {
-            // 检查 activityId 是否是有效的 ObjectId 格式
-            if (!(0, mongoose_1.isObjectIdOrHexString)(activityId)) {
-                return res.status(400).json({ success: false, message: "Invalid activityId" });
-            }
-            filters.activityId = activityId;
-        }
         if (status) {
             const statusNum = parseInt(status, 10);
             if (!isNaN(statusNum)) {
@@ -129,28 +122,6 @@ class MessageRecordController {
             return res.status(500).json({ success: false, message: "Server error", error: error.message });
         }
     }
-    /**
-     * @route GET /api/message-records/activity/:activityId
-     * @desc Retrieves message records by activity ID
-     * @access Private
-     */
-    async getRecordsByActivityId(req, res) {
-        try {
-            // 检查 activityId 是否是有效的 ObjectId 格式
-            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.activityId)) {
-                return res.status(400).json({ success: false, message: "Invalid activityId" });
-            }
-            const records = await messageRecordModel_1.MessageRecord.find({ activityId: req.params.activityId }).sort({ createdAt: -1 });
-            if (!records || records.length === 0) {
-                return res.status(404).json({ success: false, message: "No records found for this activity" });
-            }
-            return res.status(200).json({ success: true, data: records });
-        }
-        catch (error) {
-            console.error("Error fetching records by activity ID:", error);
-            return res.status(500).json({ success: false, message: "Server error", error: error.message });
-        }
-    }
     /**
      * @route GET /api/message-record/:id
      * @desc Retrieves a single message record by ID

+ 128 - 0
oms/dist/src/controllers/messageStrategyController.js

@@ -0,0 +1,128 @@
+"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 });
+const messageStrategyService = __importStar(require("../services/messageStrategyService"));
+class MessageStrategyController {
+    /**
+     * @route GET /api/message-strategies
+     * @desc 获取所有消息策略
+     * @access Private
+     */
+    async getStrategies(req, res) {
+        try {
+            const strategies = await messageStrategyService.getStrategies();
+            return res.status(200).json(strategies);
+        }
+        catch (error) {
+            console.error("获取消息策略失败:", error);
+            return res.status(500).json({ success: false, message: "无法获取消息策略列表。" });
+        }
+    }
+    /**
+     * @route GET /api/message-strategy/:id
+     * @desc 根据ID获取单个消息策略
+     * @access Private
+     */
+    async getStrategyById(req, res) {
+        try {
+            const { id } = req.params;
+            const strategy = await messageStrategyService.getStrategyById(id);
+            if (!strategy) {
+                return res.status(404).json({ success: false, message: "消息策略未找到。" });
+            }
+            return res.status(200).json({ success: true, data: strategy });
+        }
+        catch (error) {
+            console.error(`获取消息策略失败 (ID: ${req.params.id}):`, error);
+            return res.status(500).json({ success: false, message: "无法获取消息策略。" });
+        }
+    }
+    /**
+     * @route POST /api/message-strategy
+     * @desc 创建一个新的消息策略
+     * @access Private
+     */
+    async createStrategy(req, res) {
+        try {
+            const strategyData = req.body;
+            const newStrategy = await messageStrategyService.createStrategy(strategyData);
+            return res.status(201).json({ success: true, data: newStrategy });
+        }
+        catch (error) {
+            console.error("创建消息策略失败:", error);
+            return res.status(500).json({ success: false, message: "创建消息策略失败。" });
+        }
+    }
+    /**
+     * @route PUT /api/message-strategy/:id
+     * @desc 更新一个消息策略
+     * @access Private
+     */
+    async updateStrategy(req, res) {
+        try {
+            const { id } = req.params;
+            const updateData = req.body;
+            const updatedStrategy = await messageStrategyService.updateStrategy(id, updateData);
+            if (!updatedStrategy) {
+                return res.status(404).json({ success: false, message: "消息策略未找到。" });
+            }
+            return res.status(200).json({ success: true, data: updatedStrategy });
+        }
+        catch (error) {
+            console.error(`更新消息策略失败 (ID: ${req.params.id}):`, error);
+            return res.status(500).json({ success: false, message: "更新消息策略失败。" });
+        }
+    }
+    /**
+     * @route DELETE /api/message-strategy/:id
+     * @desc 删除一个消息策略
+     * @access Private
+     */
+    async deleteStrategy(req, res) {
+        try {
+            const { id } = req.params;
+            const deletedStrategy = await messageStrategyService.deleteStrategy(id);
+            if (!deletedStrategy) {
+                return res.status(404).json({ success: false, message: "消息策略未找到。" });
+            }
+            return res.status(200).json({ success: true, message: "消息策略已成功删除。" });
+        }
+        catch (error) {
+            console.error(`删除消息策略失败 (ID: ${req.params.id}):`, error);
+            return res.status(500).json({ success: false, message: "删除消息策略失败。" });
+        }
+    }
+}
+exports.default = new MessageStrategyController();

+ 23 - 2
oms/dist/src/models/messageRecordModel.js

@@ -10,19 +10,40 @@ const messageRecordSchema = new mongoose_1.Schema({
         required: true,
         index: true, // Index for faster lookup by user
     },
-    // 消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
+    // 关联消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
     activityId: {
         type: mongoose_1.Schema.Types.ObjectId,
         ref: "MessageActivity",
         required: false, // It's optional as per design
         index: true, // Added index for better query performance by activity
     },
-    // 关联的消息模版表,不太重要了,也可能是空值,对于点对点发送消息的情况可能没有模版
+    // 关联消息活动名称
+    activityName: {
+        type: String,
+        index: true,
+    },
+    // 关联的消息模版id,也可能是空值,对于点对点发送消息的情况可能没有模版
     templateId: {
         type: mongoose_1.Schema.Types.ObjectId,
         ref: "MessageTemplate",
         required: false, // It's optional as per design
     },
+    // 关联的消息模板名称
+    templateName: {
+        type: String,
+        index: true,
+    },
+    // 关联的策略id,也可能是空值,对于点对点发送消息的情况可能没有模版
+    strategyId: {
+        type: mongoose_1.Schema.Types.ObjectId,
+        ref: "MessageTemplate",
+        required: false, // It's optional as per design
+    },
+    // 关联的消息策略名称
+    strategyName: {
+        type: String,
+        index: true,
+    },
     // 已经确定了具体语言的消息标题,必须
     title: {
         type: String,

+ 30 - 0
oms/dist/src/models/messageStrategyModel.js

@@ -0,0 +1,30 @@
+"use strict";
+// oms/src/src/models/messageStrategyModel.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageStrategy = void 0;
+const mongoose_1 = require("mongoose");
+/**
+ * Mongoose Schema for MessageStrategy
+ */
+const MessageStrategySchema = new mongoose_1.Schema({
+    name: {
+        type: String,
+        required: true,
+        unique: true,
+        trim: true,
+    },
+    description: {
+        type: String,
+    },
+    templates: [
+        {
+            type: mongoose_1.Schema.Types.ObjectId,
+            ref: "MessageTemplate", // 引用 MessageTemplate 模型
+            default: [],
+        },
+    ],
+}, {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt
+});
+// 导出 MessageStrategy 模型
+exports.MessageStrategy = (0, mongoose_1.model)("MessageStrategy", MessageStrategySchema);

+ 1 - 0
oms/dist/src/models/messageTemplateModel.js

@@ -79,6 +79,7 @@ const MessageTemplateSchema = new mongoose_1.Schema({
         required: true,
         enum: Object.values(TemplateType).filter((value) => typeof value === "number"), // 使用枚举值来约束此字段
         default: TemplateType.OTHER,
+        index: true,
     },
     description: { type: String }, // 描述
     messageTitle: { type: Object, of: String, required: true }, // 消息标题,使用嵌套对象支持多语言

+ 7 - 1
oms/dist/src/routes/apiRoutes.js

@@ -8,6 +8,7 @@ const userController_1 = __importDefault(require("../controllers/userController"
 const artController_1 = __importDefault(require("../controllers/artController"));
 const doneRateController_1 = __importDefault(require("../controllers/doneRateController"));
 const messageTemplateController_1 = __importDefault(require("../controllers/messageTemplateController"));
+const messageStrategyController_1 = __importDefault(require("../controllers/messageStrategyController")); // 新增:导入消息策略控制器
 const messageActivityController_1 = __importDefault(require("../controllers/messageActivityController")); // 新增:导入消息活动控制器
 const messageRecordController_1 = __importDefault(require("../controllers/messageRecordController")); // 新增:导入消息记录控制器
 const userTargetingController_1 = __importDefault(require("../controllers/userTargetingController"));
@@ -41,6 +42,12 @@ router.get("/message-template", messageTemplateController_1.default.getAllTempla
 router.get("/message-template/:templateName", messageTemplateController_1.default.getTemplateByName);
 router.put("/message-template/:templateName", messageTemplateController_1.default.updateTemplate);
 router.delete("/message-template/:templateName", messageTemplateController_1.default.deleteTemplate);
+// 新增:消息策略路由
+router.post("/message-strategy", messageStrategyController_1.default.createStrategy);
+router.get("/message-strategies", messageStrategyController_1.default.getStrategies);
+router.get("/message-strategy/:id", messageStrategyController_1.default.getStrategyById);
+router.put("/message-strategy/:id", messageStrategyController_1.default.updateStrategy);
+router.delete("/message-strategy/:id", messageStrategyController_1.default.deleteStrategy);
 // 新增:消息活动路由
 router.post("/message-activity", messageActivityController_1.default.createActivity);
 router.get("/message-activities", messageActivityController_1.default.getActivities);
@@ -54,7 +61,6 @@ router.post("/users/count", userTargetingController_1.default.countTargetUsers);
 router.post("/message-record", messageRecordController_1.default.createRecord);
 router.get("/message-records", messageRecordController_1.default.getPaginatedRecords); // 新增分页接口
 router.get("/message-records/user/:uid", messageRecordController_1.default.getRecordsByUid);
-router.get("/message-records/activity/:activityId", messageRecordController_1.default.getRecordsByActivityId);
 router.get("/message-record/:id", messageRecordController_1.default.getRecordById);
 router.put("/message-record/:id", messageRecordController_1.default.updateRecord);
 // 管理员路由

+ 312 - 0
oms/dist/src/scripts/active-user-daily-notify.ts.js

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

+ 4 - 4
oms/dist/src/services/messageRecordService.js

@@ -27,11 +27,11 @@ class MessageRecordService {
         if (filters.uid) {
             query.uid = filters.uid;
         }
-        if (filters.activityId) {
-            query.activityId = filters.activityId;
+        if (filters.activityName) {
+            query.activityName = filters.activityName;
         }
-        if (filters.templateId) {
-            query.templateId = filters.templateId;
+        if (filters.templateName) {
+            query.templateName = filters.templateName;
         }
         if (filters.status !== undefined) {
             query.status = filters.status;

+ 122 - 0
oms/dist/src/services/messageStrategyService.js

@@ -0,0 +1,122 @@
+"use strict";
+// oms/src/src/services/messageStrategyService.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.deleteStrategy = exports.updateStrategy = exports.createStrategy = exports.getStrategyByName = exports.getStrategyById = exports.getStrategies = void 0;
+const messageStrategyModel_1 = require("../models/messageStrategyModel");
+const messageTemplateModel_1 = require("../models/messageTemplateModel");
+/**
+ * 获取所有消息策略列表,按创建时间降序排列。
+ * @returns 包含消息策略对象的 Promise,其中 templates 字段已填充。
+ */
+const getStrategies = async () => {
+    try {
+        const strategies = await messageStrategyModel_1.MessageStrategy.find({})
+            .sort({ createdAt: -1 }) // 按创建时间降序排列
+            .populate({
+            path: "templates",
+            select: "templateName description",
+            model: messageTemplateModel_1.MessageTemplate,
+        })
+            .exec();
+        return strategies;
+    }
+    catch (error) {
+        console.error("获取消息策略列表失败:", error);
+        throw new Error("无法获取消息策略列表。");
+    }
+};
+exports.getStrategies = getStrategies;
+/**
+ * 根据 ID 获取单个消息策略的详情。
+ * @param id 消息策略的 ObjectId
+ * @returns 包含单个消息策略对象的 Promise,如果未找到则返回 null。
+ */
+const getStrategyById = async (id) => {
+    try {
+        const strategy = await messageStrategyModel_1.MessageStrategy.findById(id)
+            .populate({
+            path: "templates",
+            select: "templateName description",
+            model: messageTemplateModel_1.MessageTemplate,
+        })
+            .exec();
+        return strategy;
+    }
+    catch (error) {
+        console.error(`获取 ID 为 ${id} 的消息策略失败:`, error);
+        throw new Error(`无法获取 ID 为 ${id} 的消息策略。`);
+    }
+};
+exports.getStrategyById = getStrategyById;
+/**
+ * 根据 name 获取单个消息策略的详情。
+ * @param id 消息策略的 ObjectId
+ * @returns 包含单个消息策略对象的 Promise,如果未找到则返回 null。
+ */
+const getStrategyByName = async (name) => {
+    try {
+        const strategy = await messageStrategyModel_1.MessageStrategy.findOne({ name })
+            .populate({
+            path: "templates",
+            select: "templateName description",
+            model: messageTemplateModel_1.MessageTemplate,
+        })
+            .exec();
+        return strategy;
+    }
+    catch (error) {
+        console.error(`获取 name 为 ${name} 的消息策略失败:`, error);
+        throw new Error(`无法获取 ID 为 ${name} 的消息策略。`);
+    }
+};
+exports.getStrategyByName = getStrategyByName;
+/**
+ * 创建一个新的消息策略。
+ * @param strategyData 要创建的策略数据。
+ * @returns 创建成功的消息策略对象。
+ */
+const createStrategy = async (strategyData) => {
+    try {
+        const newStrategy = new messageStrategyModel_1.MessageStrategy(strategyData);
+        const savedStrategy = await newStrategy.save();
+        return savedStrategy;
+    }
+    catch (error) {
+        console.error("创建消息策略失败:", error);
+        throw new Error("无法创建消息策略。");
+    }
+};
+exports.createStrategy = createStrategy;
+/**
+ * 根据 ID 更新消息策略。
+ * @param id 消息策略的 ObjectId。
+ * @param updateData 要更新的字段数据。
+ * @returns 更新后的消息策略对象。
+ */
+const updateStrategy = async (id, updateData) => {
+    try {
+        const updatedStrategy = await messageStrategyModel_1.MessageStrategy.findByIdAndUpdate(id, updateData, { new: true });
+        return updatedStrategy;
+    }
+    catch (error) {
+        console.error(`更新 ID 为 ${id} 的消息策略失败:`, error);
+        throw new Error(`无法更新 ID 为 ${id} 的消息策略。`);
+    }
+};
+exports.updateStrategy = updateStrategy;
+/**
+ * 根据 ID 删除消息策略。
+ * @param id 消息策略的 ObjectId。
+ * @returns 删除成功的消息策略对象。
+ */
+const deleteStrategy = async (id) => {
+    try {
+        const deletedStrategy = await messageStrategyModel_1.MessageStrategy.findByIdAndDelete(id);
+        return deletedStrategy;
+    }
+    catch (error) {
+        console.error(`删除 ID 为 ${id} 的消息策略失败:`, error);
+        throw new Error(`无法删除 ID 为 ${id} 的消息策略。`);
+    }
+};
+exports.deleteStrategy = deleteStrategy;

+ 1 - 1
oms/public/app/index.html

@@ -9,5 +9,5 @@
   <style>body,html{width:100%;height:100%}*,:after,:before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0}</style><link rel="stylesheet" href="styles-LXBSU6DF.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-LXBSU6DF.css"></noscript></head>
   <body>
     <app-root></app-root>
-  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-NVZNF4PY.js" type="module"></script></body>
+  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-2PI2FCIZ.js" type="module"></script></body>
 </html>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
oms/public/app/main-2PI2FCIZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
oms/public/app/main-NVZNF4PY.js


+ 2 - 30
oms/src/controllers/messageRecordController.ts

@@ -25,7 +25,7 @@ class MessageRecordController {
    * @access Private
    */
   public async getPaginatedRecords(req: Request, res: Response): Promise<Response> {
-    const { page = 1, limit = 30, uid, activityId, status, startDate, endDate } = req.query;
+    const { page = 1, limit = 30, uid, activityName, templateName, strategyName, status, startDate, endDate } = req.query;
 
     const pageNum = parseInt(page as string, 10);
     const limitNum = parseInt(limit as string, 10);
@@ -35,13 +35,7 @@ class MessageRecordController {
     if (uid) {
       filters.uid = uid;
     }
-    if (activityId) {
-      // 检查 activityId 是否是有效的 ObjectId 格式
-      if (!isObjectIdOrHexString(activityId as string)) {
-        return res.status(400).json({ success: false, message: "Invalid activityId" });
-      }
-      filters.activityId = activityId;
-    }
+
     if (status) {
       const statusNum = parseInt(status as string, 10);
       if (!isNaN(statusNum)) {
@@ -136,28 +130,6 @@ class MessageRecordController {
     }
   }
 
-  /**
-   * @route GET /api/message-records/activity/:activityId
-   * @desc Retrieves message records by activity ID
-   * @access Private
-   */
-  public async getRecordsByActivityId(req: Request, res: Response): Promise<Response> {
-    try {
-      // 检查 activityId 是否是有效的 ObjectId 格式
-      if (!isObjectIdOrHexString(req.params.activityId)) {
-        return res.status(400).json({ success: false, message: "Invalid activityId" });
-      }
-      const records = await MessageRecord.find({ activityId: req.params.activityId }).sort({ createdAt: -1 });
-      if (!records || records.length === 0) {
-        return res.status(404).json({ success: false, message: "No records found for this activity" });
-      }
-      return res.status(200).json({ success: true, data: records });
-    } catch (error: any) {
-      console.error("Error fetching records by activity ID:", error);
-      return res.status(500).json({ success: false, message: "Server error", error: error.message });
-    }
-  }
-
   /**
    * @route GET /api/message-record/:id
    * @desc Retrieves a single message record by ID

+ 101 - 0
oms/src/controllers/messageStrategyController.ts

@@ -0,0 +1,101 @@
+import { Request, Response } from "express";
+import * as messageStrategyService from "../services/messageStrategyService";
+
+class MessageStrategyController {
+  /**
+   * @route GET /api/message-strategies
+   * @desc 获取所有消息策略
+   * @access Private
+   */
+  public async getStrategies(req: Request, res: Response): Promise<Response> {
+    try {
+      const strategies = await messageStrategyService.getStrategies();
+      return res.status(200).json(strategies);
+    } catch (error: any) {
+      console.error("获取消息策略失败:", error);
+      return res.status(500).json({ success: false, message: "无法获取消息策略列表。" });
+    }
+  }
+
+  /**
+   * @route GET /api/message-strategy/:id
+   * @desc 根据ID获取单个消息策略
+   * @access Private
+   */
+  public async getStrategyById(req: Request, res: Response): Promise<Response> {
+    try {
+      const { id } = req.params;
+      const strategy = await messageStrategyService.getStrategyById(id);
+
+      if (!strategy) {
+        return res.status(404).json({ success: false, message: "消息策略未找到。" });
+      }
+
+      return res.status(200).json({ success: true, data: strategy });
+    } catch (error: any) {
+      console.error(`获取消息策略失败 (ID: ${req.params.id}):`, error);
+      return res.status(500).json({ success: false, message: "无法获取消息策略。" });
+    }
+  }
+
+  /**
+   * @route POST /api/message-strategy
+   * @desc 创建一个新的消息策略
+   * @access Private
+   */
+  public async createStrategy(req: Request, res: Response): Promise<Response> {
+    try {
+      const strategyData = req.body;
+      const newStrategy = await messageStrategyService.createStrategy(strategyData);
+      return res.status(201).json({ success: true, data: newStrategy });
+    } catch (error: any) {
+      console.error("创建消息策略失败:", error);
+      return res.status(500).json({ success: false, message: "创建消息策略失败。" });
+    }
+  }
+
+  /**
+   * @route PUT /api/message-strategy/:id
+   * @desc 更新一个消息策略
+   * @access Private
+   */
+  public async updateStrategy(req: Request, res: Response): Promise<Response> {
+    try {
+      const { id } = req.params;
+      const updateData = req.body;
+      const updatedStrategy = await messageStrategyService.updateStrategy(id, updateData);
+
+      if (!updatedStrategy) {
+        return res.status(404).json({ success: false, message: "消息策略未找到。" });
+      }
+
+      return res.status(200).json({ success: true, data: updatedStrategy });
+    } catch (error: any) {
+      console.error(`更新消息策略失败 (ID: ${req.params.id}):`, error);
+      return res.status(500).json({ success: false, message: "更新消息策略失败。" });
+    }
+  }
+
+  /**
+   * @route DELETE /api/message-strategy/:id
+   * @desc 删除一个消息策略
+   * @access Private
+   */
+  public async deleteStrategy(req: Request, res: Response): Promise<Response> {
+    try {
+      const { id } = req.params;
+      const deletedStrategy = await messageStrategyService.deleteStrategy(id);
+
+      if (!deletedStrategy) {
+        return res.status(404).json({ success: false, message: "消息策略未找到。" });
+      }
+
+      return res.status(200).json({ success: true, message: "消息策略已成功删除。" });
+    } catch (error: any) {
+      console.error(`删除消息策略失败 (ID: ${req.params.id}):`, error);
+      return res.status(500).json({ success: false, message: "删除消息策略失败。" });
+    }
+  }
+}
+
+export default new MessageStrategyController();

+ 33 - 2
oms/src/models/messageRecordModel.ts

@@ -4,7 +4,11 @@ import { Schema, model, Document } from "mongoose";
 export interface IMessageRecord extends Document {
   uid: string; // Storing as string to match User model's uid
   activityId?: Schema.Types.ObjectId;
+  activityName?: string;
   templateId?: Schema.Types.ObjectId;
+  templateName?: string;
+  strategyId?: Schema.Types.ObjectId;
+  strategyName?: string;
   title: string;
   content: string;
   image?: string;
@@ -33,19 +37,46 @@ const messageRecordSchema = new Schema<IMessageRecord>(
       required: true,
       index: true, // Index for faster lookup by user
     },
-    // 消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
+    // 关联消息活动, 关联到消息活动表(messageActivity),表明次消息记录来自于那个宣传活动;有可能是空值,对于点对点发送消息的情况可能没有关联的活动
     activityId: {
       type: Schema.Types.ObjectId,
       ref: "MessageActivity",
       required: false, // It's optional as per design
       index: true, // Added index for better query performance by activity
     },
-    // 关联的消息模版表,不太重要了,也可能是空值,对于点对点发送消息的情况可能没有模版
+
+    // 关联消息活动名称
+    activityName: {
+      type: String,
+      index: true,
+    },
+
+    // 关联的消息模版id,也可能是空值,对于点对点发送消息的情况可能没有模版
     templateId: {
       type: Schema.Types.ObjectId,
       ref: "MessageTemplate",
       required: false, // It's optional as per design
     },
+
+    // 关联的消息模板名称
+    templateName: {
+      type: String,
+      index: true,
+    },
+
+    // 关联的策略id,也可能是空值,对于点对点发送消息的情况可能没有模版
+    strategyId: {
+      type: Schema.Types.ObjectId,
+      ref: "MessageTemplate",
+      required: false, // It's optional as per design
+    },
+
+    // 关联的消息策略名称
+    strategyName: {
+      type: String,
+      index: true,
+    },
+
     // 已经确定了具体语言的消息标题,必须
     title: {
       type: String,

+ 46 - 0
oms/src/models/messageStrategyModel.ts

@@ -0,0 +1,46 @@
+// oms/src/src/models/messageStrategyModel.ts
+
+import { Schema, model, Document } from "mongoose";
+import { IMessageTemplate } from "./messageTemplateModel";
+
+/**
+ * 消息策略的接口定义。
+ * templates 字段现在存储 MessageTemplate 的 ObjectId。
+ */
+export interface IMessageStrategy extends Document {
+  name: string; // 策略名称,唯一标识
+  description?: string; // 策略描述
+  templates: (Schema.Types.ObjectId | IMessageTemplate)[]; // 绑定的消息模板 ObjectId 数组
+  createdAt: Date; // Mongoose 自动生成
+  updatedAt: Date; // Mongoose 自动生成
+}
+
+/**
+ * Mongoose Schema for MessageStrategy
+ */
+const MessageStrategySchema: Schema = new Schema(
+  {
+    name: {
+      type: String,
+      required: true,
+      unique: true,
+      trim: true,
+    },
+    description: {
+      type: String,
+    },
+    templates: [
+      {
+        type: Schema.Types.ObjectId,
+        ref: "MessageTemplate", // 引用 MessageTemplate 模型
+        default: [],
+      },
+    ],
+  },
+  {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt
+  }
+);
+
+// 导出 MessageStrategy 模型
+export const MessageStrategy = model<IMessageStrategy>("MessageStrategy", MessageStrategySchema);

+ 8 - 1
oms/src/routes/apiRoutes.ts

@@ -3,6 +3,7 @@ import userController from "../controllers/userController";
 import artController from "../controllers/artController";
 import doneRateController from "../controllers/doneRateController";
 import messageTemplateController from "../controllers/messageTemplateController";
+import messageStrategyController from "../controllers/messageStrategyController"; // 新增:导入消息策略控制器
 import messageActivityController from "../controllers/messageActivityController"; // 新增:导入消息活动控制器
 import messageRecordController from "../controllers/messageRecordController"; // 新增:导入消息记录控制器
 import UserTargetingController from "../controllers/userTargetingController";
@@ -46,6 +47,13 @@ router.get("/message-template/:templateName", messageTemplateController.getTempl
 router.put("/message-template/:templateName", messageTemplateController.updateTemplate);
 router.delete("/message-template/:templateName", messageTemplateController.deleteTemplate);
 
+// 新增:消息策略路由
+router.post("/message-strategy", messageStrategyController.createStrategy);
+router.get("/message-strategies", messageStrategyController.getStrategies);
+router.get("/message-strategy/:id", messageStrategyController.getStrategyById);
+router.put("/message-strategy/:id", messageStrategyController.updateStrategy);
+router.delete("/message-strategy/:id", messageStrategyController.deleteStrategy);
+
 // 新增:消息活动路由
 router.post("/message-activity", messageActivityController.createActivity);
 router.get("/message-activities", messageActivityController.getActivities);
@@ -61,7 +69,6 @@ router.post("/users/count", UserTargetingController.countTargetUsers);
 router.post("/message-record", messageRecordController.createRecord);
 router.get("/message-records", messageRecordController.getPaginatedRecords); // 新增分页接口
 router.get("/message-records/user/:uid", messageRecordController.getRecordsByUid);
-router.get("/message-records/activity/:activityId", messageRecordController.getRecordsByActivityId);
 router.get("/message-record/:id", messageRecordController.getRecordById);
 router.put("/message-record/:id", messageRecordController.updateRecord);
 

+ 311 - 0
oms/src/scripts/active-user-daily-notify.ts.ts

@@ -0,0 +1,311 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+import { MessageStrategy, IMessageStrategy } from "../models/messageStrategyModel";
+
+import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+import { FCMService } from "../services/fcmService";
+import { connectToDatabase, disconnectFromDatabase } from "../../src/database";
+
+const strategyName = "active_new_content_notify";
+const fcmService = FCMService.getInstance();
+
+// 定义一个简单的 Art 接口和模型,用于查询
+interface IArt extends Document {
+  tags: string[];
+  publishTime: Date;
+  _id: mongoose.Types.ObjectId;
+}
+const ArtSchema: Schema = new Schema({
+  tags: [{ type: String }],
+  publishTime: { type: Date, required: true },
+});
+const Art = mongoose.model<IArt>("Art", ArtSchema, "arts");
+
+// 国家代码到语言的映射表,包含您提供的 top10 国家,可根据需求扩展
+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 || "",
+  };
+}
+
+/**
+ * 获取今日的用于消息推送的2幅画作。
+ * 查询 art 表,查找 tags 标签等于 'fcm' 的记录,按 publishTime 倒序排,取头部的2个 art。
+ * @returns 包含两个画作 ID 的数组 [art_id1, art_id2]
+ */
+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[]>();
+
+    // 如果找到,返回 ID 数组;如果不足2个,则返回找到的所有 ID
+    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 fcmToken 用户的FCM Token
+ * @param template 消息模板
+ * @param messageData 消息数据
+ * @param strategyId 消息策略ID
+ * @param strategyName 消息策略名称
+ */
+const sendAndRecordMessage = 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 {
+    // 1. 先创建 MessageRecord 记录,状态为 0 (未发送)
+    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,
+    });
+
+    // 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.findByIdAndUpdate(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外部调用。
+ */
+export async function run() {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  // 在启动所有定时任务之前,首先建立数据库连接
+  await connectToDatabase();
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_id, fmToken, lang, cc),避免加载整个文档
+    const activeUsers = await User.find({
+      // lastActiveAt: { $gte: sevenDaysAgo },
+      fmToken: { $nin: [null, ""] },
+      versionName: { $in: ["5.8.0-debug"] },
+      //   versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+    })
+      .select("_id fmToken lang cc")
+      .lean<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 < 2) {
+      console.error(`未找到策略 '${strategyName}' 或其绑定的消息模板,或模板数量不足2个。`);
+      return;
+    }
+    const templates = strategy.templates as IMessageTemplate[];
+    console.log(`找到 ${templates.length} 个消息模板。`);
+
+    const todaysArtworks = await getTodaysArtworksForFCM();
+    if (todaysArtworks.length < 2) {
+      console.warn("今日用于FCM消息推送的画作数量不足2个,无法执行双消息策略。脚本结束。");
+      return;
+    }
+    const artwork1Id = todaysArtworks[0];
+    const artwork2Id = todaysArtworks[1];
+    console.log(`今日画作ID:${artwork1Id} 和 ${artwork2Id}`);
+
+    // 为每个用户预先选择并存储两条消息的模板
+    const messagesToSend = activeUsers.map((user) => {
+      const randomIndex1 = Math.floor(Math.random() * templates.length);
+      let randomIndex2 = Math.floor(Math.random() * templates.length);
+      while (randomIndex2 === randomIndex1) {
+        randomIndex2 = Math.floor(Math.random() * templates.length);
+      }
+      return {
+        user,
+        template1: templates[randomIndex1],
+        template2: templates[randomIndex2],
+      };
+    });
+
+    // --- 立即发送第一批消息 ---
+    console.log("\n开始发送第一批消息...");
+    for (const messageData of messagesToSend) {
+      const user = messageData.user;
+      const userLang = getUserLanguage(user);
+      const fcmToken = user.fmToken as string;
+
+      const data1 = getMessageDataFromTemplate(messageData.template1, userLang);
+      data1.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artwork1Id}.png`;
+      data1.bigger = "true";
+      data1.action = "go/art";
+      data1.param = artwork1Id;
+
+      await sendAndRecordMessage(user._id, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
+    }
+    console.log("第一批消息发送完成。");
+
+    // --- 设置单个定时器,延迟30分钟后发送第二批消息 ---
+    setTimeout(async () => {
+      console.log("\n定时任务触发:开始发送第二批消息...");
+      for (const messageData of messagesToSend) {
+        const user = messageData.user;
+        const userLang = getUserLanguage(user);
+        const fcmToken = user.fmToken as string;
+
+        const data2 = getMessageDataFromTemplate(messageData.template2, userLang);
+        data2.image = `https://d1e6q48ob2nxw1.cloudfront.net/thumbs/v2/page/640/${artwork2Id}.png`;
+        data2.bigger = "true";
+        data2.action = "go/art";
+        data2.param = artwork2Id;
+
+        await sendAndRecordMessage(user._id, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
+      }
+      console.log("第二批消息发送完成。");
+    }, 30 * 60 * 1000);
+
+    console.log("脚本执行完毕。第二批消息将在30分钟后发送。");
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+  } finally {
+    // 脚本执行完毕,您可以在这里调用数据库断开连接
+    await disconnectFromDatabase(); // 在退出前断开数据库连接
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

+ 4 - 4
oms/src/services/messageRecordService.ts

@@ -32,11 +32,11 @@ export class MessageRecordService {
     if (filters.uid) {
       query.uid = filters.uid;
     }
-    if (filters.activityId) {
-      query.activityId = filters.activityId;
+    if (filters.activityName) {
+      query.activityName = filters.activityName;
     }
-    if (filters.templateId) {
-      query.templateId = filters.templateId;
+    if (filters.templateName) {
+      query.templateName = filters.templateName;
     }
     if (filters.status !== undefined) {
       query.status = filters.status;

+ 114 - 0
oms/src/services/messageStrategyService.ts

@@ -0,0 +1,114 @@
+// oms/src/src/services/messageStrategyService.ts
+
+import { MessageStrategy, IMessageStrategy } from "../models/messageStrategyModel";
+import { IMessageTemplate, MessageTemplate } from "../models/messageTemplateModel";
+
+/**
+ * 获取所有消息策略列表,按创建时间降序排列。
+ * @returns 包含消息策略对象的 Promise,其中 templates 字段已填充。
+ */
+export const getStrategies = async (): Promise<IMessageStrategy[]> => {
+  try {
+    const strategies = await MessageStrategy.find({})
+      .sort({ createdAt: -1 }) // 按创建时间降序排列
+      .populate({
+        path: "templates",
+        select: "templateName description",
+        model: MessageTemplate,
+      })
+      .exec();
+    return strategies as IMessageStrategy[];
+  } catch (error) {
+    console.error("获取消息策略列表失败:", error);
+    throw new Error("无法获取消息策略列表。");
+  }
+};
+
+/**
+ * 根据 ID 获取单个消息策略的详情。
+ * @param id 消息策略的 ObjectId
+ * @returns 包含单个消息策略对象的 Promise,如果未找到则返回 null。
+ */
+export const getStrategyById = async (id: string): Promise<IMessageStrategy | null> => {
+  try {
+    const strategy = await MessageStrategy.findById(id)
+      .populate({
+        path: "templates",
+        select: "templateName description",
+        model: MessageTemplate,
+      })
+      .exec();
+    return strategy as IMessageStrategy | null;
+  } catch (error) {
+    console.error(`获取 ID 为 ${id} 的消息策略失败:`, error);
+    throw new Error(`无法获取 ID 为 ${id} 的消息策略。`);
+  }
+};
+
+/**
+ * 根据 name 获取单个消息策略的详情。
+ * @param id 消息策略的 ObjectId
+ * @returns 包含单个消息策略对象的 Promise,如果未找到则返回 null。
+ */
+export const getStrategyByName = async (name: string): Promise<IMessageStrategy | null> => {
+  try {
+    const strategy = await MessageStrategy.findOne({ name })
+      .populate({
+        path: "templates",
+        select: "templateName description",
+        model: MessageTemplate,
+      })
+      .exec();
+    return strategy as IMessageStrategy | null;
+  } catch (error) {
+    console.error(`获取 name 为 ${name} 的消息策略失败:`, error);
+    throw new Error(`无法获取 ID 为 ${name} 的消息策略。`);
+  }
+};
+
+/**
+ * 创建一个新的消息策略。
+ * @param strategyData 要创建的策略数据。
+ * @returns 创建成功的消息策略对象。
+ */
+export const createStrategy = async (strategyData: Partial<IMessageStrategy>): Promise<IMessageStrategy> => {
+  try {
+    const newStrategy = new MessageStrategy(strategyData);
+    const savedStrategy = await newStrategy.save();
+    return savedStrategy;
+  } catch (error) {
+    console.error("创建消息策略失败:", error);
+    throw new Error("无法创建消息策略。");
+  }
+};
+
+/**
+ * 根据 ID 更新消息策略。
+ * @param id 消息策略的 ObjectId。
+ * @param updateData 要更新的字段数据。
+ * @returns 更新后的消息策略对象。
+ */
+export const updateStrategy = async (id: string, updateData: Partial<IMessageStrategy>): Promise<IMessageStrategy | null> => {
+  try {
+    const updatedStrategy = await MessageStrategy.findByIdAndUpdate(id, updateData, { new: true });
+    return updatedStrategy;
+  } catch (error) {
+    console.error(`更新 ID 为 ${id} 的消息策略失败:`, error);
+    throw new Error(`无法更新 ID 为 ${id} 的消息策略。`);
+  }
+};
+
+/**
+ * 根据 ID 删除消息策略。
+ * @param id 消息策略的 ObjectId。
+ * @returns 删除成功的消息策略对象。
+ */
+export const deleteStrategy = async (id: string): Promise<IMessageStrategy | null> => {
+  try {
+    const deletedStrategy = await MessageStrategy.findByIdAndDelete(id);
+    return deletedStrategy;
+  } catch (error) {
+    console.error(`删除 ID 为 ${id} 的消息策略失败:`, error);
+    throw new Error(`无法删除 ID 为 ${id} 的消息策略。`);
+  }
+};

+ 2 - 0
omsapp/src/app/app.routes.ts

@@ -8,6 +8,7 @@ import { DashboardComponent } from './pages/dashboard.component'; // 修正:
 import { MessageTemplateComponent } from './pages/message-template.component';
 import { MessageActivityComponent } from './pages/message-activity.component';
 import { MessageRecordComponent } from './pages/message-record.component';
+import { MessageStrategyComponent } from './pages/message-strategy.component';
 
 export const routes: Routes = [
   { path: 'login', component: LoginComponent },
@@ -23,6 +24,7 @@ export const routes: Routes = [
       { path: 'message-activity', component: MessageActivityComponent },
       { path: 'message-record', component: MessageRecordComponent },
       { path: 'message-template', component: MessageTemplateComponent },
+      { path: 'message-strategy', component: MessageStrategyComponent },
     ],
   },
   // 顶级重定向:确保应用启动时重定向到 '/dashboard'

+ 10 - 0
omsapp/src/app/layouts/main-layout.component.ts

@@ -97,6 +97,15 @@ interface TabItem {
                   <span nz-icon nzType="cluster"></span>
                   <span>消息模板</span>
                 </li>
+                <li
+                  nz-menu-item
+                  [routerLink]="['/message-strategy']"
+                  [routerLinkActive]="['ant-menu-item-selected']"
+                  [nzSelected]="activePath === '/message-strategy'"
+                >
+                  <span nz-icon nzType="ant-design"></span>
+                  <span>推送策略</span>
+                </li>
               </ul>
             </li>
           </ul>
@@ -402,6 +411,7 @@ export class MainLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
     '/message-activity': '消息通知',
     '/message-record': '推送记录',
     '/message-template': '消息模板',
+    '/message-strategy': '推送策略',
   };
 
   constructor(private router: Router, private authService: AuthService) {}

+ 73 - 11
omsapp/src/app/pages/message-record.component.ts

@@ -73,7 +73,7 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
       <form nz-form [formGroup]="filterForm" class="filter-form">
         <div nz-row [nzGutter]="16">
           <!-- 基本筛选条件 -->
-          <div nz-col [nzSpan]="6">
+          <div nz-col [nzSpan]="4">
             <nz-form-item>
               <nz-input-group nzPrefixIcon="user">
                 <input nz-input placeholder="用户ID" formControlName="uid" />
@@ -81,19 +81,43 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
             </nz-form-item>
           </div>
 
-          <div nz-col [nzSpan]="6">
+          <div nz-col [nzSpan]="4">
             <nz-form-item>
               <nz-input-group nzPrefixIcon="project">
                 <input
                   nz-input
-                  placeholder="活动ID"
-                  formControlName="activityId"
+                  placeholder="活动"
+                  formControlName="activityName"
                 />
               </nz-input-group>
             </nz-form-item>
           </div>
 
-          <div nz-col [nzSpan]="6">
+          <div nz-col [nzSpan]="4">
+            <nz-form-item>
+              <nz-input-group nzPrefixIcon="ant-design">
+                <input
+                  nz-input
+                  placeholder="策略"
+                  formControlName="strategyName"
+                />
+              </nz-input-group>
+            </nz-form-item>
+          </div>
+
+          <div nz-col [nzSpan]="4">
+            <nz-form-item>
+              <nz-input-group nzPrefixIcon="cluster">
+                <input
+                  nz-input
+                  placeholder="模板"
+                  formControlName="templateName"
+                />
+              </nz-input-group>
+            </nz-form-item>
+          </div>
+
+          <div nz-col [nzSpan]="4">
             <nz-form-item>
               <nz-select
                 nzPlaceHolder="状态"
@@ -109,7 +133,7 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
             </nz-form-item>
           </div>
 
-          <div nz-col [nzSpan]="6" class="button-col">
+          <div nz-col [nzSpan]="4" class="button-col">
             <button nz-button (click)="resetFilters()">重置</button>
           </div>
         </div>
@@ -171,8 +195,10 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
           <thead>
             <tr>
               <th>用户ID</th>
-              <th>活动ID</th>
-              <th>标题</th>
+              <th>消息</th>
+              <th>活动</th>
+              <th>策略</th>
+              <th>模板</th>
               <th>状态</th>
               <th>计划发送时间</th>
               <th>实际发送时间</th>
@@ -187,8 +213,18 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
               <td>
                 <a (click)="showUserDetail(record.uid)">{{ record.uid }}</a>
               </td>
-              <td>{{ record.activityId || '-' }}</td>
-              <td>{{ record.title }}</td>
+              <td class="message-content-cell">
+                <div class="message-title">
+                  {{ record.title }}
+                </div>
+                <div class="message-content">
+                  {{ record.content }}
+                </div>
+              </td>
+              <td>{{ record.activityName || '-' }}</td>
+              <td>{{ record.strategyName || '-' }}</td>
+              <td>{{ record.templateName || '-' }}</td>
+
               <td>
                 <nz-tag [nzColor]="getStatusColor(record.status)">
                   {{ getStatusName(record.status) }}
@@ -276,6 +312,30 @@ import { UserDetailModalComponent } from './user-detail-modal.component';
           justify-content: flex-start;
         }
       }
+
+      /* 消息内容单元格样式 */
+      .message-content-cell {
+        max-width: 300px;
+        min-width: 200px;
+      }
+      /* 消息标题样式 */
+      .message-title {
+        font-weight: bold;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        margin-bottom: 4px;
+      }
+      /* 消息内容样式 */
+      .message-content {
+        display: -webkit-box;
+        -webkit-line-clamp: 2;
+        -webkit-box-orient: vertical;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        line-height: 1.4;
+        color: #666;
+      }
     `,
   ],
 })
@@ -304,7 +364,9 @@ export class MessageRecordComponent implements OnInit, OnDestroy {
   ) {
     this.filterForm = this.fb.group({
       uid: [null],
-      activityId: [null],
+      activityName: [null],
+      templateName: [null],
+      strategyName: [null],
       status: [null],
       plannedSendAt: [null],
       actualSendAt: [null],

+ 488 - 0
omsapp/src/app/pages/message-strategy.component.ts

@@ -0,0 +1,488 @@
+import { Component, OnInit, OnDestroy } from '@angular/core';
+import { CommonModule, DatePipe } from '@angular/common';
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  FormBuilder,
+  FormGroup,
+  FormControl,
+  Validators,
+} from '@angular/forms';
+import { Router, ActivatedRoute } from '@angular/router';
+import { of, Subject } from 'rxjs';
+import {
+  takeUntil,
+  debounceTime,
+  distinctUntilChanged,
+  map,
+  catchError,
+} from 'rxjs/operators';
+
+// NG-ZORRO 组件
+import { NzTableModule } from 'ng-zorro-antd/table';
+import { NzDividerModule } from 'ng-zorro-antd/divider';
+import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzIconModule } from 'ng-zorro-antd/icon';
+import { NzFormModule } from 'ng-zorro-antd/form';
+import { NzInputModule } from 'ng-zorro-antd/input';
+import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal';
+import { NzSelectModule } from 'ng-zorro-antd/select';
+import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
+import { NzCardModule } from 'ng-zorro-antd/card';
+import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
+import { NzTagModule } from 'ng-zorro-antd/tag';
+import { NzTabsModule } from 'ng-zorro-antd/tabs';
+import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+
+// 服务
+import {
+  IMessageStrategy,
+  MessageStrategyData,
+  MessageService,
+  IMessageTemplate,
+  IMessageTemplateInfo,
+} from '../services/message.service';
+
+@Component({
+  selector: 'app-message-strategy',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    ReactiveFormsModule,
+    NzTableModule,
+    NzDividerModule,
+    NzButtonModule,
+    NzIconModule,
+    NzFormModule,
+    NzInputModule,
+    NzModalModule,
+    NzSelectModule,
+    NzPageHeaderModule,
+    NzCardModule,
+    NzPopconfirmModule,
+    NzTagModule,
+    NzTabsModule,
+    NzDescriptionsModule,
+    NzEmptyModule,
+    NzSpinModule,
+    DatePipe,
+  ],
+  template: `
+    <nz-page-header [nzGhost]="false">
+      <nz-page-header-title>消息策略管理</nz-page-header-title>
+      <nz-page-header-extra>
+        <button nz-button nzType="primary" (click)="showCreateModal()">
+          <span nz-icon nzType="plus"></span>新建策略
+        </button>
+      </nz-page-header-extra>
+      <nz-page-header-content>
+        管理消息推送策略,用于组合多个消息模板,方便统计评估策略优劣
+      </nz-page-header-content>
+    </nz-page-header>
+
+    <nz-card>
+      <nz-spin [nzSpinning]="isLoading">
+        <nz-table
+          #strategiesTable
+          [nzData]="filteredStrategies"
+          [nzLoading]="isLoading"
+          [nzFrontPagination]="false"
+          [nzBordered]="true"
+          [nzSize]="'small'"
+          [nzShowPagination]="false"
+        >
+          <thead>
+            <tr>
+              <th>策略名称</th>
+              <th>描述</th>
+              <th>关联模板</th>
+              <th>创建时间</th>
+              <th>更新时间</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            @for (strategy of strategiesTable.data; track strategy._id) {
+            <tr>
+              <td>{{ strategy.name }}</td>
+              <td>{{ strategy.description || '-' }}</td>
+              <td>
+                @for (template of strategy.templates; track template._id) {
+                <nz-tag>{{ template.templateName }}</nz-tag>
+                }
+              </td>
+              <td>{{ strategy.createdAt | date : 'yyyy-MM-dd HH:mm' }}</td>
+              <td>{{ strategy.updatedAt | date : 'yyyy-MM-dd HH:mm' }}</td>
+              <td>
+                <a (click)="showEditModal(strategy)">编辑</a>
+                <nz-divider nzType="vertical"></nz-divider>
+                <a (click)="showDetailModal(strategy)">详情</a>
+                <nz-divider nzType="vertical"></nz-divider>
+                <a
+                  nz-popconfirm
+                  nzPopconfirmTitle="确定要删除此策略吗?"
+                  nzPopconfirmOkText="确定"
+                  nzPopconfirmCancelText="取消"
+                  (nzOnConfirm)="deleteStrategy(strategy._id)"
+                  >删除
+                </a>
+              </td>
+            </tr>
+            }
+          </tbody>
+        </nz-table>
+
+        @if (filteredStrategies.length === 0 && !isLoading) {
+        <nz-empty nzNotFoundContent="暂无消息策略"></nz-empty>
+        }
+      </nz-spin>
+    </nz-card>
+
+    <!-- 创建/编辑策略模态框 -->
+    <nz-modal
+      [(nzVisible)]="isModalVisible"
+      [nzTitle]="isEditMode ? '编辑消息策略' : '新建消息策略'"
+      [nzOkText]="isEditMode ? '更新' : '创建'"
+      [nzCancelText]="'取消'"
+      (nzOnCancel)="handleCancel()"
+      (nzOnOk)="handleOk()"
+      [nzOkLoading]="isSubmitting"
+      [nzWidth]="800"
+    >
+      <form nz-form [formGroup]="strategyForm" *nzModalContent>
+        <nz-form-item>
+          <nz-form-label nzRequired>策略名称</nz-form-label>
+          <nz-form-control>
+            <input
+              nz-input
+              formControlName="name"
+              placeholder="输入策略名称(英文,不可重复)"
+            />
+          </nz-form-control>
+        </nz-form-item>
+
+        <nz-form-item>
+          <nz-form-label>描述</nz-form-label>
+          <nz-form-control>
+            <textarea
+              nz-input
+              formControlName="description"
+              placeholder="输入策略描述(可选)"
+              rows="3"
+            ></textarea>
+          </nz-form-control>
+        </nz-form-item>
+
+        <nz-form-item>
+          <nz-form-label nzRequired>关联模板</nz-form-label>
+          <nz-form-control>
+            <nz-select
+              formControlName="templates"
+              nzMode="multiple"
+              nzPlaceHolder="选择关联的消息模板"
+              nzShowSearch
+              nzAllowClear
+            >
+              @for (template of getAllTemplates(); track template._id) {
+              <nz-option
+                [nzLabel]="template.templateName"
+                [nzValue]="template._id"
+              ></nz-option>
+              }
+            </nz-select>
+          </nz-form-control>
+        </nz-form-item>
+      </form>
+    </nz-modal>
+
+    <!-- 策略详情模态框 -->
+    <nz-modal
+      [(nzVisible)]="isDetailModalVisible"
+      [nzTitle]="'策略详情 - ' + (selectedStrategy?.name || '')"
+      [nzFooter]="null"
+      (nzOnCancel)="isDetailModalVisible = false"
+      [nzWidth]="800"
+    >
+      <ng-container *nzModalContent>
+        <nz-descriptions nzBordered [nzColumn]="1">
+          <nz-descriptions-item nzTitle="策略名称">{{
+            selectedStrategy?.name
+          }}</nz-descriptions-item>
+          <nz-descriptions-item nzTitle="描述">{{
+            selectedStrategy?.description || '-'
+          }}</nz-descriptions-item>
+          <nz-descriptions-item nzTitle="创建时间">{{
+            selectedStrategy?.createdAt | date : 'yyyy-MM-dd HH:mm:ss'
+          }}</nz-descriptions-item>
+          <nz-descriptions-item nzTitle="更新时间">{{
+            selectedStrategy?.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'
+          }}</nz-descriptions-item>
+        </nz-descriptions>
+
+        <nz-card nzTitle="关联模板" style="margin-top: 16px">
+          <nz-table
+            #templatesTable
+            [nzData]="selectedStrategy?.templates || []"
+            [nzShowPagination]="false"
+            [nzFrontPagination]="false"
+            [nzSize]="'small'"
+          >
+            <thead>
+              <tr>
+                <th>模板名称</th>
+                <th>描述</th>
+              </tr>
+            </thead>
+            <tbody>
+              @for (template of templatesTable.data; track template._id) {
+              <tr>
+                <td>{{ template.templateName }}</td>
+                <td>{{ template.description || '-' }}</td>
+              </tr>
+              }
+            </tbody>
+          </nz-table>
+        </nz-card>
+      </ng-container>
+    </nz-modal>
+  `,
+  styles: [
+    `
+      nz-card {
+        margin-bottom: 16px;
+      }
+
+      .ant-tag {
+        margin-bottom: 4px;
+      }
+
+      .ant-form-item {
+        margin-bottom: 16px;
+      }
+
+      textarea.ant-input {
+        resize: none;
+      }
+
+      .ant-empty {
+        margin: 40px 0;
+      }
+
+      .ant-table {
+        margin-top: 16px;
+      }
+
+      nz-tag {
+        margin-right: 4px;
+      }
+    `,
+  ],
+})
+export class MessageStrategyComponent implements OnInit, OnDestroy {
+  strategies: IMessageStrategy[] = [];
+  filteredStrategies: IMessageStrategy[] = [];
+  allTemplates: IMessageTemplate[] = []; // 新增:存储所有模板
+  isLoading = false;
+  isModalVisible = false;
+  isDetailModalVisible = false;
+  isEditMode = false;
+  isSubmitting = false;
+  selectedStrategy: IMessageStrategy | null = null;
+
+  // 表单
+  strategyForm: FormGroup;
+
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    private messageService: MessageService,
+    private fb: FormBuilder,
+    private message: NzMessageService,
+    private modal: NzModalService,
+    private router: Router,
+    private route: ActivatedRoute
+  ) {
+    this.strategyForm = this.fb.group({
+      name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_-]+$/)]],
+      description: [''],
+      templates: [[], [Validators.required]],
+    });
+  }
+
+  ngOnInit(): void {
+    this.loadStrategies();
+    this.loadAllTemplates(); // 新增:加载所有模板
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  // 新增:加载所有模板
+  loadAllTemplates(): void {
+    this.messageService
+      .getAllTemplates()
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (templates) => {
+          this.allTemplates = templates || [];
+        },
+        error: (err) => {
+          this.message.error('加载模板失败: ' + err.message);
+          this.allTemplates = [];
+        },
+      });
+  }
+
+  loadStrategies(): void {
+    this.isLoading = true;
+    this.messageService
+      .getAllStrategies()
+      .pipe(
+        takeUntil(this.destroy$),
+        map((strategies) => (Array.isArray(strategies) ? strategies : [])),
+        catchError((err) => {
+          this.message.error('加载策略失败: ' + err.message);
+          return of([]);
+        })
+      )
+      .subscribe({
+        next: (strategies) => {
+          this.strategies = strategies;
+          this.filteredStrategies = [...strategies];
+          this.isLoading = false;
+        },
+        error: (err) => {
+          this.message.error('加载策略失败: ' + err.message);
+          this.isLoading = false;
+          this.strategies = [];
+          this.filteredStrategies = [];
+        },
+      });
+  }
+
+  // 修改:直接从 allTemplates 获取所有模板
+  getAllTemplates(): IMessageTemplate[] {
+    return this.allTemplates;
+  }
+
+  showCreateModal(): void {
+    this.isEditMode = false;
+    this.selectedStrategy = null;
+    this.strategyForm.reset({
+      templates: [],
+    });
+    this.isModalVisible = true;
+  }
+
+  showEditModal(strategy: IMessageStrategy): void {
+    this.isEditMode = true;
+    this.selectedStrategy = strategy;
+    this.strategyForm.reset({
+      name: strategy.name,
+      description: strategy.description || '',
+      templates: strategy.templates?.map((t) => t._id) || [],
+    });
+    this.isModalVisible = true;
+  }
+
+  showDetailModal(strategy: IMessageStrategy): void {
+    this.selectedStrategy = strategy;
+    this.isDetailModalVisible = true;
+  }
+
+  handleOk(): void {
+    if (this.strategyForm.invalid) {
+      this.markFormControlsAsDirty();
+      return;
+    }
+
+    this.isSubmitting = true;
+    const formValue = this.strategyForm.value;
+
+    if (this.isEditMode && this.selectedStrategy) {
+      this.updateStrategy(formValue);
+    } else {
+      this.createStrategy(formValue);
+    }
+  }
+
+  createStrategy(strategyData: MessageStrategyData): void {
+    this.messageService.createStrategy(strategyData).subscribe({
+      next: () => {
+        this.message.success('策略创建成功');
+        this.loadStrategies();
+        this.isModalVisible = false;
+        this.isSubmitting = false;
+      },
+      error: (err) => {
+        this.handleApiError(err, '创建');
+        this.isSubmitting = false;
+      },
+    });
+  }
+
+  updateStrategy(strategyData: Partial<MessageStrategyData>): void {
+    if (!this.selectedStrategy) return;
+
+    this.messageService
+      .updateStrategy(this.selectedStrategy._id, strategyData)
+      .subscribe({
+        next: () => {
+          this.message.success('策略更新成功');
+          this.loadStrategies();
+          this.isModalVisible = false;
+          this.isSubmitting = false;
+        },
+        error: (err) => {
+          this.handleApiError(err, '更新');
+          this.isSubmitting = false;
+        },
+      });
+  }
+
+  deleteStrategy(id: string): void {
+    this.messageService.deleteStrategy(id).subscribe({
+      next: () => {
+        this.message.success('策略删除成功');
+        this.loadStrategies();
+      },
+      error: (err) => {
+        this.handleApiError(err, '删除');
+      },
+    });
+  }
+
+  handleCancel(): void {
+    this.isModalVisible = false;
+  }
+
+  private markFormControlsAsDirty(): void {
+    Object.values(this.strategyForm.controls).forEach((control) => {
+      if (control instanceof FormControl) {
+        control.markAsDirty();
+        control.updateValueAndValidity();
+      }
+    });
+    this.message.error('请填写所有必填字段');
+  }
+
+  private handleApiError(err: any, action: string): void {
+    if (err.status === 409) {
+      this.message.error('策略名称已存在');
+    } else if (err.status === 404) {
+      this.message.error('未找到指定的策略');
+    } else if (err.status === 400) {
+      this.message.error(
+        '验证错误: ' + (err.error?.message || '请检查输入数据')
+      );
+    } else {
+      this.message.error(`${action}策略失败: ${err.message}`);
+    }
+  }
+}

+ 24 - 8
omsapp/src/app/pages/message-template.component.ts

@@ -10,7 +10,8 @@ import {
 } from '@angular/forms';
 import { Router, ActivatedRoute } from '@angular/router';
 import { Subscription } from 'rxjs';
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
+import { debounceTime } from 'rxjs/operators';
+import { distinctUntilChanged } from 'rxjs/operators';
 
 // NG-ZORRO 组件
 import { NzTableModule } from 'ng-zorro-antd/table';
@@ -83,7 +84,6 @@ import {
     </nz-page-header>
 
     <nz-card>
-      <!-- 筛选表单 -->
       <form nz-form [formGroup]="filterForm" class="filter-form">
         <div nz-row [nzGutter]="16">
           <div nz-col [nzSpan]="8">
@@ -150,7 +150,6 @@ import {
             @for (template of templatesTable.data; track template.templateName)
             {
             <tr>
-              <!-- <td>{{ template.templateName }}</td> -->
               <td class="message-content-cell">
                 <div class="message-title">
                   <a (click)="showDetailModal(template)">{{
@@ -216,7 +215,6 @@ import {
       </nz-spin>
     </nz-card>
 
-    <!-- 创建/编辑模板模态框 -->
     <nz-modal
       [(nzVisible)]="isModalVisible"
       [nzTitle]="isEditMode ? '编辑消息模板' : '新建消息模板'"
@@ -377,7 +375,6 @@ import {
       </form>
     </nz-modal>
 
-    <!-- 模板详情模态框 -->
     <nz-modal
       [(nzVisible)]="isDetailModalVisible"
       [nzTitle]="'模板详情 - ' + (selectedTemplate?.templateName || '')"
@@ -652,13 +649,17 @@ export class MessageTemplateComponent implements OnInit, OnDestroy {
     });
   }
 
+  // 修正后的方法:确保在值为空或为null时也能正确更新URL
   private updateUrl(): void {
     const queryParams: any = {};
     const filterValue = this.filterForm.value;
-    if (filterValue.templateName)
+    if (filterValue.templateName !== '') {
       queryParams.templateName = filterValue.templateName;
-    if (filterValue.templateType !== null)
+    }
+    if (filterValue.templateType !== null) {
       queryParams.templateType = filterValue.templateType;
+    }
+
     this.router.navigate([], {
       relativeTo: this.route,
       queryParams,
@@ -670,8 +671,23 @@ export class MessageTemplateComponent implements OnInit, OnDestroy {
     this.filterForm.patchValue({ templateType: type });
   }
 
+  // 修正后的方法:确保重置时能够清空 URL 参数
   resetFilters(): void {
-    this.filterForm.reset({ templateName: '', templateType: null });
+    // 1. 重置表单值,显式禁用事件,避免重复触发
+    this.filterForm.reset(
+      { templateName: '', templateType: null },
+      { emitEvent: false }
+    );
+
+    // 2. 强制导航并清空URL中的所有查询参数
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: {}, // 传递一个空对象来清空所有参数
+      queryParamsHandling: 'merge', // 确保新参数会覆盖旧参数
+    });
+
+    // 3. 重新加载模板数据
+    this.loadTemplates();
   }
 
   getTemplateTypeName(type: TemplateType): string {

+ 80 - 15
omsapp/src/app/services/message.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core';
 import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { catchError, map, Observable, of } from 'rxjs';
 
 // 定义模板类型枚举
 export enum TemplateType {
@@ -113,6 +113,33 @@ export interface OperatorOption {
   valueType: 'single' | 'multiple' | 'range';
 }
 
+// 更新模板信息接口
+export interface IMessageTemplateInfo {
+  _id: string;
+  templateName: string;
+  templateType?: TemplateType;
+  description?: string;
+  createdAt?: Date;
+  updatedAt?: Date;
+}
+
+// 策略接口保持不变
+export interface IMessageStrategy {
+  _id: string;
+  name: string;
+  description?: string;
+  templates: IMessageTemplateInfo[]; // 使用 IMessageTemplateInfo 类型
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// 创建策略数据接口保持不变
+export interface MessageStrategyData {
+  name: string;
+  description?: string;
+  templates: string[]; // 创建时只需要模板ID数组
+}
+
 // 定义消息活动接口
 export interface IMessageActivity {
   _id: string;
@@ -155,7 +182,11 @@ export interface IMessageRecord {
   _id: string;
   uid: string;
   activityId?: string;
+  activityName?: string;
   templateId?: string;
+  templateName?: string;
+  strategyId?: string;
+  strategyName?: string;
   title: string;
   content: string;
   image?: string;
@@ -201,6 +232,7 @@ export class MessageService {
   private activityListApiUri = '/api/message-activities';
   private recordsApiUrl = '/api/message-record';
   private recordsListApiUrl = '/api/message-records';
+  private strategiesApiUrl = '/api/message-strategy';
 
   private httpOptions = {
     headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
@@ -294,6 +326,53 @@ export class MessageService {
     );
   }
 
+  getAllStrategies(): Observable<IMessageStrategy[]> {
+    return this.http
+      .get<IMessageStrategy[]>('/api/message-strategies', this.httpOptions)
+      .pipe(
+        map((response) => (Array.isArray(response) ? response : [])),
+        catchError((err) => {
+          console.error('获取策略失败:', err);
+          return of([]);
+        })
+      );
+  }
+
+  getStrategyById(id: string): Observable<IMessageStrategy | null> {
+    return this.http.get<IMessageStrategy | null>(
+      `${this.strategiesApiUrl}/${id}`,
+      this.httpOptions
+    );
+  }
+
+  createStrategy(
+    strategyData: MessageStrategyData
+  ): Observable<IMessageStrategy> {
+    return this.http.post<IMessageStrategy>(
+      this.strategiesApiUrl,
+      strategyData,
+      this.httpOptions
+    );
+  }
+
+  updateStrategy(
+    id: string,
+    updateData: Partial<MessageStrategyData>
+  ): Observable<IMessageStrategy | null> {
+    return this.http.put<IMessageStrategy | null>(
+      `${this.strategiesApiUrl}/${id}`,
+      updateData,
+      this.httpOptions
+    );
+  }
+
+  deleteStrategy(id: string): Observable<{ deletedCount?: number }> {
+    return this.http.delete<{ deletedCount?: number }>(
+      `${this.strategiesApiUrl}/${id}`,
+      this.httpOptions
+    );
+  }
+
   /**
    * 活动管理 API
    */
@@ -462,20 +541,6 @@ export class MessageService {
     );
   }
 
-  getRecordsByUid(uid: string): Observable<IMessageRecord[]> {
-    return this.http.get<IMessageRecord[]>(
-      `${this.recordsListApiUrl}/user/${uid}`,
-      this.httpOptions
-    );
-  }
-
-  getRecordsByActivityId(activityId: string): Observable<IMessageRecord[]> {
-    return this.http.get<IMessageRecord[]>(
-      `${this.recordsListApiUrl}/activity/${activityId}`,
-      this.httpOptions
-    );
-  }
-
   getRecordById(recordId: string): Observable<IMessageRecord> {
     return this.http.get<IMessageRecord>(
       `${this.recordsApiUrl}/${recordId}`,

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff