guoziyun 9 months ago
parent
commit
0a62e09b35

+ 313 - 0
oms/dist/services/cron-jobs/active-user-daily-notify.js

@@ -0,0 +1,313 @@
+"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("../../src/models/userModel");
+// 确保先导入并注册 MessageTemplate 模型
+const messageTemplateModel_1 = require("../../src/models/messageTemplateModel");
+// 确保 MessageTemplate 模型已被注册
+mongoose_1.default.model("MessageTemplate", messageTemplateModel_1.MessageTemplate.schema);
+// 然后再导入和使用 MessageStrategy 模型
+const messageStrategyModel_1 = require("../../src/models/messageStrategyModel");
+const messageRecordModel_1 = require("../../src/models/messageRecordModel");
+const fcmService_1 = require("../../src/services/fcmService");
+const 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.findOneAndUpdate({ uid: uid }, { fmToken: null });
+            console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+            errorMessage += " (Token cleared)";
+        }
+        else {
+            console.error(`发送消息给用户 ${uid} 失败:`, error);
+        }
+    }
+    finally {
+        // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+        if (messageRecord) {
+            await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
+                status: messageStatus,
+                fcmReceipt: fcmReceipt,
+                actualSendAt: new Date(),
+                errno: errorMessage,
+            });
+        }
+    }
+};
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    try {
+        const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+        // 性能优化:只查询需要的字段(_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"] },
+            versionCode: { $gte: 347 },
+        })
+            .select("_id uid fmToken lang cc")
+            .lean();
+        if (activeUsers.length === 0) {
+            console.log("未找到符合条件的用户,脚本结束。");
+            return;
+        }
+        console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+        const strategy = await messageStrategyModel_1.MessageStrategy.findOne({ name: strategyName }).populate("templates");
+        if (!strategy || !strategy.templates || strategy.templates.length < 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.uid, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
+        }
+        console.log("第一批消息发送完成。");
+        // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
+        // return new Promise<void>((resolve) => {
+        //   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.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
+        //     }
+        //     console.log("第二批消息发送完成。");
+        //     // 所有任务完成后,安全地断开数据库连接
+        //     await disconnectFromDatabase();
+        //     resolve();
+        //   }, 30 * 60 * 1000);
+        // });
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

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

@@ -14,6 +14,7 @@ const settings = [
     ["done-rate", "10 0 * * *", require("./done-rate2")], // 每天凌晨0点10分, 统计作品完成率
     ["done-rate", "10 0 * * *", require("./done-rate2")], // 每天凌晨0点10分, 统计作品完成率
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
     ["message-sender", "*/5 * * * *", require("./message-sender")], // 每5分钟运行一次
     ["message-sender", "*/5 * * * *", require("./message-sender")], // 每5分钟运行一次
+    ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify")], // 每天下午6点,开始活跃用户新作品消息推送
 ];
 ];
 /**
 /**
  * Starts all scheduled cron jobs.
  * Starts all scheduled cron jobs.

+ 2 - 5
oms/dist/services/ingestor-service.js

@@ -283,11 +283,8 @@ async function processMessage(msg) {
     const eventType = eventData.type || eventData.name;
     const eventType = eventData.type || eventData.name;
     // --- 1. Handle Message-Specific Events First ---
     // --- 1. Handle Message-Specific Events First ---
     if (["message_receive", "message_open"].includes(eventType)) {
     if (["message_receive", "message_open"].includes(eventType)) {
-        await handleMessageEvent(eventData, eventType);
-        amqpChannel.ack(msg); // Acknowledge message after processing message events
-        return;
-    }
-    // Filter by allowed event types
+        await handleMessageEvent(eventData, eventType); // 移除 amqpChannel.ack(msg); 和 return; // 让事件继续向下流转,以便被记录到ClickHouse和更新User表
+    } // Filter by allowed event types
     if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
     if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
         // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
         // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
         amqpChannel.ack(msg); // Acknowledge and drop unsupported events
         amqpChannel.ack(msg); // Acknowledge and drop unsupported events

+ 261 - 157
oms/dist/src/controllers/messageRecordController.js

@@ -1,170 +1,274 @@
 "use strict";
 "use strict";
+// oms/src/controllers/messageRecordController.ts
 Object.defineProperty(exports, "__esModule", { value: true });
 Object.defineProperty(exports, "__esModule", { value: true });
-const messageRecordModel_1 = require("../models/messageRecordModel");
 const mongoose_1 = require("mongoose");
 const mongoose_1 = require("mongoose");
+const messageRecordModel_1 = require("../models/messageRecordModel");
+const messageRecordService_1 = require("../services/messageRecordService");
 class MessageRecordController {
 class MessageRecordController {
-    /**
-     * @route POST /api/message-record
-     * @desc Creates a new message record
-     * @access Private
-     */
-    async createRecord(req, res) {
-        try {
-            const newRecord = new messageRecordModel_1.MessageRecord(req.body);
-            await newRecord.save();
-            return res.status(201).json({ success: true, data: newRecord });
-        }
-        catch (error) {
-            console.error("Error creating message record:", error);
-            return res.status(500).json({ success: false, message: "Server error", error: error.message });
-        }
-    }
-    /**
-     * @route GET /api/message-records
-     * @desc Retrieves all message records with pagination and optional filters
-     * @access Private
-     */
-    async getPaginatedRecords(req, res) {
-        const { page = 1, limit = 30, uid, activityName, templateName, strategyName, status, startDate, endDate } = req.query;
-        const pageNum = parseInt(page, 10);
-        const limitNum = parseInt(limit, 10);
-        // 动态构建查询过滤器
-        const filters = {};
-        if (uid) {
-            filters.uid = uid;
-        }
-        if (status) {
-            const statusNum = parseInt(status, 10);
-            if (!isNaN(statusNum)) {
-                filters.status = statusNum;
-            }
-        }
-        // 定义所有可查询的日期字段
-        const dateQueryKeys = ["plannedSendAt", "actualSendAt", "deliveredAt", "openedAt", "createdAt", "updatedAt"];
-        // 遍历所有日期字段,处理单个日期或日期范围
-        dateQueryKeys.forEach((key) => {
-            const queryValue = req.query[key];
-            if (queryValue) {
-                const dates = queryValue.split(",");
-                if (dates.length === 2) {
-                    const startDate = new Date(dates[0]);
-                    const endDate = new Date(dates[1]);
-                    // 确保日期有效
-                    if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
-                        filters[key] = {
-                            $gte: startDate,
-                            $lte: endDate,
-                        };
+    constructor() {
+        /**
+         * @route POST /api/message-record
+         * @desc Creates a new message record
+         * @access Private
+         */
+        this.createRecord = async (req, res) => {
+            try {
+                const newRecord = new messageRecordModel_1.MessageRecord(req.body);
+                await newRecord.save();
+                return res.status(201).json({ success: true, data: newRecord });
+            }
+            catch (error) {
+                console.error("Error creating message record:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records
+         * @desc Retrieves all message records with pagination and optional filters
+         * @access Private
+         */
+        this.getPaginatedRecords = async (req, res) => {
+            const { page = 1, limit = 30, uid, activityName, templateName, strategyName, status, startDate, endDate } = req.query;
+            const pageNum = parseInt(page, 10);
+            const limitNum = parseInt(limit, 10);
+            // 动态构建查询过滤器
+            const filters = {};
+            if (uid) {
+                filters.uid = uid;
+            }
+            if (status) {
+                const statusNum = parseInt(status, 10);
+                if (!isNaN(statusNum)) {
+                    filters.status = statusNum;
+                }
+            }
+            if (activityName) {
+                filters.activityName = activityName;
+            }
+            if (strategyName) {
+                filters.strategyName = strategyName;
+            }
+            if (templateName) {
+                filters.templateName = templateName;
+            }
+            // 定义所有可查询的日期字段
+            const dateQueryKeys = ["plannedSendAt", "actualSendAt", "deliveredAt", "openedAt", "createdAt", "updatedAt"];
+            // 遍历所有日期字段,处理单个日期或日期范围
+            dateQueryKeys.forEach((key) => {
+                const queryValue = req.query[key];
+                if (queryValue) {
+                    const dates = queryValue.split(",");
+                    if (dates.length === 2) {
+                        const startDate = new Date(dates[0]);
+                        const endDate = new Date(dates[1]);
+                        // 确保日期有效
+                        if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
+                            filters[key] = {
+                                $gte: startDate,
+                                $lte: endDate,
+                            };
+                        }
+                        else {
+                            console.warn(`[API] Invalid date range format for ${key}: ${queryValue}. Skipping.`);
+                        }
                     }
                     }
                     else {
                     else {
-                        console.warn(`[API] Invalid date range format for ${key}: ${queryValue}. Skipping.`);
+                        // 如果不是范围,则按单个日期精确匹配
+                        const singleDate = new Date(queryValue);
+                        if (!isNaN(singleDate.getTime())) {
+                            filters[key] = singleDate;
+                        }
                     }
                     }
                 }
                 }
-                else {
-                    // 如果不是范围,则按单个日期精确匹配
-                    const singleDate = new Date(queryValue);
-                    if (!isNaN(singleDate.getTime())) {
-                        filters[key] = singleDate;
-                    }
+            });
+            // 保留对原有startDate/endDate参数的兼容性,并将其应用于createdAt
+            if (startDate || endDate) {
+                // 确保 createdAt 过滤器不存在冲突,如果已通过范围查询设置,则跳过
+                if (!filters.createdAt) {
+                    filters.createdAt = {};
+                }
+                if (startDate) {
+                    filters.createdAt.$gte = new Date(startDate);
+                }
+                if (endDate) {
+                    filters.createdAt.$lte = new Date(endDate);
                 }
                 }
             }
             }
-        });
-        // 保留对原有startDate/endDate参数的兼容性,并将其应用于createdAt
-        if (startDate || endDate) {
-            // 确保 createdAt 过滤器不存在冲突,如果已通过范围查询设置,则跳过
-            if (!filters.createdAt) {
-                filters.createdAt = {};
-            }
-            if (startDate) {
-                filters.createdAt.$gte = new Date(startDate);
-            }
-            if (endDate) {
-                filters.createdAt.$lte = new Date(endDate);
-            }
-        }
-        try {
-            const records = await messageRecordModel_1.MessageRecord.find(filters)
-                .sort({ createdAt: -1 })
-                .skip((pageNum - 1) * limitNum)
-                .limit(limitNum);
-            const total = await messageRecordModel_1.MessageRecord.countDocuments(filters);
-            return res.status(200).json({
-                success: true,
-                data: records,
-                pagination: {
-                    total,
-                    page: pageNum,
-                    limit: limitNum,
-                    totalPages: Math.ceil(total / limitNum),
-                },
-            });
-        }
-        catch (error) {
-            console.error("Error fetching paginated records:", error);
-            return res.status(500).json({ success: false, message: "Server error", error: error.message });
-        }
-    }
-    /**
-     * @route GET /api/message-records/user/:uid
-     * @desc Retrieves message records by user UID
-     * @access Private
-     */
-    async getRecordsByUid(req, res) {
-        try {
-            const records = await messageRecordModel_1.MessageRecord.find({ uid: req.params.uid }).sort({ createdAt: -1 });
-            if (!records || records.length === 0) {
-                return res.status(404).json({ success: false, message: "No records found for this user" });
-            }
-            return res.status(200).json({ success: true, data: records });
-        }
-        catch (error) {
-            console.error("Error fetching records by user UID:", 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
-     * @access Private
-     */
-    async getRecordById(req, res) {
-        try {
-            // 检查 id 是否是有效的 ObjectId 格式
-            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
-                return res.status(400).json({ success: false, message: "Invalid record ID" });
-            }
-            const record = await messageRecordModel_1.MessageRecord.findById(req.params.id);
-            if (!record) {
-                return res.status(404).json({ success: false, message: "Message record not found" });
-            }
-            return res.status(200).json({ success: true, data: record });
-        }
-        catch (error) {
-            console.error("Error fetching message record by ID:", error);
-            return res.status(500).json({ success: false, message: "Server error", error: error.message });
-        }
-    }
-    /**
-     * @route PUT /api/message-record/:id
-     * @desc Updates the status of a message record
-     * @access Private
-     */
-    async updateRecord(req, res) {
-        try {
-            // 检查 id 是否是有效的 ObjectId 格式
-            if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
-                return res.status(400).json({ success: false, message: "Invalid record ID" });
-            }
-            const updatedRecord = await messageRecordModel_1.MessageRecord.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
-            if (!updatedRecord) {
-                return res.status(404).json({ success: false, message: "Message record not found" });
-            }
-            return res.status(200).json({ success: true, data: updatedRecord });
-        }
-        catch (error) {
-            console.error("Error updating message record:", error);
-            return res.status(500).json({ success: false, message: "Server error", error: error.message });
-        }
+            try {
+                const records = await messageRecordModel_1.MessageRecord.find(filters)
+                    .sort({ createdAt: -1 })
+                    .skip((pageNum - 1) * limitNum)
+                    .limit(limitNum);
+                const total = await messageRecordModel_1.MessageRecord.countDocuments(filters);
+                return res.status(200).json({
+                    success: true,
+                    data: records,
+                    pagination: {
+                        total,
+                        page: pageNum,
+                        limit: limitNum,
+                        totalPages: Math.ceil(total / limitNum),
+                    },
+                });
+            }
+            catch (error) {
+                console.error("Error fetching paginated records:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/user/:uid
+         * @desc Retrieves message records by user UID
+         * @access Private
+         */
+        this.getRecordsByUid = async (req, res) => {
+            try {
+                const records = await messageRecordModel_1.MessageRecord.find({ uid: req.params.uid }).sort({ createdAt: -1 });
+                if (!records || records.length === 0) {
+                    return res.status(404).json({ success: false, message: "No records found for this user" });
+                }
+                return res.status(200).json({ success: true, data: records });
+            }
+            catch (error) {
+                console.error("Error fetching records by user UID:", 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
+         * @access Private
+         */
+        this.getRecordById = async (req, res) => {
+            try {
+                // 检查 id 是否是有效的 ObjectId 格式
+                if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
+                    return res.status(400).json({ success: false, message: "Invalid record ID" });
+                }
+                const record = await messageRecordModel_1.MessageRecord.findById(req.params.id);
+                if (!record) {
+                    return res.status(404).json({ success: false, message: "Message record not found" });
+                }
+                return res.status(200).json({ success: true, data: record });
+            }
+            catch (error) {
+                console.error("Error fetching message record by ID:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route PUT /api/message-record/:id
+         * @desc Updates the status of a message record
+         * @access Private
+         */
+        this.updateRecord = async (req, res) => {
+            try {
+                // 检查 id 是否是有效的 ObjectId 格式
+                if (!(0, mongoose_1.isObjectIdOrHexString)(req.params.id)) {
+                    return res.status(400).json({ success: false, message: "Invalid record ID" });
+                }
+                const updatedRecord = await messageRecordModel_1.MessageRecord.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
+                if (!updatedRecord) {
+                    return res.status(404).json({ success: false, message: "Message record not found" });
+                }
+                return res.status(200).json({ success: true, data: updatedRecord });
+            }
+            catch (error) {
+                console.error("Error updating message record:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/overall
+         * @desc Retrieves overall message push statistics
+         * @access Private
+         */
+        this.getOverallStatistics = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getOverallStatistics();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching overall statistics:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/by-activity
+         * @desc Retrieves message push statistics grouped by activity
+         * @access Private
+         */
+        this.getStatisticsByActivity = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getStatisticsByActivity();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching statistics by activity:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/by-strategy
+         * @desc Retrieves message push statistics grouped by strategy
+         * @access Private
+         */
+        this.getStatisticsByStrategy = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getStatisticsByStrategy();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching statistics by strategy:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/by-template
+         * @desc Retrieves message push statistics grouped by template
+         * @access Private
+         */
+        this.getStatisticsByTemplate = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getStatisticsByTemplate();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching statistics by strategy:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/daily-trends
+         * @desc Retrieves daily sent trend statistics
+         * @access Private
+         */
+        this.getDailySentTrends = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getDailySentTrends();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching daily sent trends:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        /**
+         * @route GET /api/message-records/statistics/avg-delivery-time
+         * @desc Retrieves average message delivery time
+         * @access Private
+         */
+        this.getAverageDeliveryTime = async (req, res) => {
+            try {
+                const stats = await this.messageRecordService.getAverageDeliveryTime();
+                return res.status(200).json({ success: true, data: stats });
+            }
+            catch (error) {
+                console.error("Error fetching average delivery time:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
+        this.messageRecordService = new messageRecordService_1.MessageRecordService();
     }
     }
 }
 }
 exports.default = new MessageRecordController();
 exports.default = new MessageRecordController();

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

@@ -63,6 +63,13 @@ router.get("/message-records", messageRecordController_1.default.getPaginatedRec
 router.get("/message-records/user/:uid", messageRecordController_1.default.getRecordsByUid);
 router.get("/message-records/user/:uid", messageRecordController_1.default.getRecordsByUid);
 router.get("/message-record/:id", messageRecordController_1.default.getRecordById);
 router.get("/message-record/:id", messageRecordController_1.default.getRecordById);
 router.put("/message-record/:id", messageRecordController_1.default.updateRecord);
 router.put("/message-record/:id", messageRecordController_1.default.updateRecord);
+// 新增:消息记录统计路由
+router.get("/message-records/statistics/overall", messageRecordController_1.default.getOverallStatistics);
+router.get("/message-records/statistics/by-activity", messageRecordController_1.default.getStatisticsByActivity);
+router.get("/message-records/statistics/by-strategy", messageRecordController_1.default.getStatisticsByStrategy);
+router.get("/message-records/statistics/by-template", messageRecordController_1.default.getStatisticsByTemplate);
+router.get("/message-records/statistics/daily-trends", messageRecordController_1.default.getDailySentTrends);
+router.get("/message-records/statistics/avg-delivery-time", messageRecordController_1.default.getAverageDeliveryTime);
 // 管理员路由
 // 管理员路由
 router.get("/admin", adminController_1.default.getAdmins);
 router.get("/admin", adminController_1.default.getAdmins);
 router.get("/admin/:id", adminController_1.default.getAdminById);
 router.get("/admin/:id", adminController_1.default.getAdminById);

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

@@ -0,0 +1,322 @@
+"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("../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.findOneAndUpdate({ uid: uid }, { fmToken: null });
+            console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+            errorMessage += " (Token cleared)";
+        }
+        else {
+            console.error(`发送消息给用户 ${uid} 失败:`, error);
+        }
+    }
+    finally {
+        // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+        if (messageRecord) {
+            await messageRecordModel_1.MessageRecord.findByIdAndUpdate(messageRecord._id, {
+                status: messageStatus,
+                fcmReceipt: fcmReceipt,
+                actualSendAt: new Date(),
+                errno: errorMessage,
+            });
+        }
+    }
+};
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+async function run() {
+    console.log("脚本开始:发送活跃用户每日通知...");
+    // 在启动所有任务之前,首先建立数据库连接
+    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"] },
+            versionCode: { $gte: 347 },
+        })
+            .select("_id uid fmToken lang cc")
+            .lean();
+        if (activeUsers.length === 0) {
+            console.log("未找到符合条件的用户,脚本结束。");
+            await (0, database_1.disconnectFromDatabase)();
+            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个。`);
+            await (0, database_1.disconnectFromDatabase)();
+            return;
+        }
+        const templates = strategy.templates;
+        console.log(`找到 ${templates.length} 个消息模板。`);
+        const todaysArtworks = await getTodaysArtworksForFCM();
+        if (todaysArtworks.length < 2) {
+            console.warn("今日用于FCM消息推送的画作数量不足2个,无法执行双消息策略。脚本结束。");
+            await (0, database_1.disconnectFromDatabase)();
+            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.uid, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
+        }
+        console.log("第一批消息发送完成。");
+        // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
+        await (0, database_1.disconnectFromDatabase)(); // 先注释掉先不发第二轮消息了
+        // return new Promise<void>((resolve) => {
+        //   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.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
+        //     }
+        //     console.log("第二批消息发送完成。");
+        //     // 所有任务完成后,安全地断开数据库连接
+        //     await disconnectFromDatabase();
+        //     resolve();
+        //   }, 30 * 60 * 1000);
+        // });
+    }
+    catch (error) {
+        console.error("脚本执行过程中发生致命错误:", error);
+        // 如果在第一阶段发生错误,确保断开数据库连接
+        await (0, database_1.disconnectFromDatabase)();
+        throw error;
+    }
+}
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+    run()
+        .then(() => {
+        console.log("脚本执行完毕,退出进程。");
+        process.exit(0);
+    })
+        .catch((err) => {
+        console.error("脚本执行失败:", err);
+        process.exit(1);
+    });
+}

+ 537 - 0
oms/dist/src/services/messageRecordService copy.js

@@ -0,0 +1,537 @@
+"use strict";
+// oms/src/services/messageRecordService.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageRecordService = void 0;
+const messageRecordModel_1 = require("../models/messageRecordModel");
+class MessageRecordService {
+    /**
+     * Creates a new message push record.
+     * @param recordData Message record data.
+     * @returns The newly created message record object.
+     */
+    async createMessageRecord(recordData) {
+        const newRecord = new messageRecordModel_1.MessageRecord(recordData);
+        return await newRecord.save();
+    }
+    /**
+     * Gets paginated message records with filtering and sorting support.
+     * @param page Page number.
+     * @param limit Number of records per page.
+     * @param filters Filter conditions.
+     * @param sortField Field to sort by.
+     * @param sortOrder Sort order ('asc' or 'desc').
+     * @returns An object containing records and the total count.
+     */
+    async getPaginatedRecords(page = 1, limit = 10, filters = {}, sortField = "createdAt", sortOrder = "desc") {
+        // Build the query conditions
+        const query = {};
+        if (filters.uid) {
+            query.uid = filters.uid;
+        }
+        if (filters.activityName) {
+            query.activityName = filters.activityName;
+        }
+        if (filters.templateName) {
+            query.templateName = filters.templateName;
+        }
+        if (filters.status !== undefined) {
+            query.status = filters.status;
+        }
+        const sort = {};
+        sort[sortField] = sortOrder === "asc" ? 1 : -1;
+        const skip = (page - 1) * limit;
+        const records = await messageRecordModel_1.MessageRecord.find(query).sort(sort).skip(skip).limit(limit);
+        const total = await messageRecordModel_1.MessageRecord.countDocuments(query);
+        return { records, total };
+    }
+    /**
+     * Gets all message records for a specific user UID.
+     * @param uid User UID.
+     * @returns A list of message records.
+     */
+    async getRecordsByUid(uid) {
+        return await messageRecordModel_1.MessageRecord.find({ uid }).sort({ createdAt: -1 });
+    }
+    /**
+     * Gets all message records related to a specific activity ID.
+     * @param activityId Message activity ID.
+     * @returns A list of message records.
+     */
+    async getRecordsByActivityId(activityId) {
+        return await messageRecordModel_1.MessageRecord.find({ activityId }).sort({ createdAt: -1 });
+    }
+    /**
+     * Gets a single message record by ID.
+     * @param recordId Message record ID.
+     * @returns The message record object or null.
+     */
+    async getSingleRecord(recordId) {
+        return await messageRecordModel_1.MessageRecord.findById(recordId);
+    }
+    /**
+     * Updates the status of a message record.
+     * @param recordId Message record ID.
+     * @param updateData Data to update.
+     * @returns The updated message record object.
+     */
+    async updateMessageRecord(recordId, updateData) {
+        return await messageRecordModel_1.MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
+    }
+    /**
+     * Gets overall message push statistics.
+     */
+    async getOverallStatistics() {
+        try {
+            const result = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Group messages by status
+                {
+                    $group: {
+                        _id: "$status",
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. Transform the result into a more manageable format
+                {
+                    $group: {
+                        _id: null,
+                        total_records: { $sum: "$count" },
+                        status_counts: {
+                            $push: {
+                                k: { $toString: "$_id" },
+                                v: "$count",
+                            },
+                        },
+                    },
+                },
+                // 3. Reformat the output and calculate all rates
+                {
+                    $project: {
+                        _id: 0,
+                        totalRecords: "$total_records",
+                        sent: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0],
+                        },
+                        delivered: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0],
+                        },
+                        opened: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0],
+                        },
+                        failed: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0],
+                        },
+                        // Sent success rate = (sent + delivered + opened) / total
+                        sentSuccessRate: {
+                            $cond: [
+                                { $eq: ["$total_records", 0] },
+                                0,
+                                {
+                                    $divide: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                        "$total_records",
+                                    ],
+                                },
+                            ],
+                        },
+                        // Delivered rate = delivered / (sent + delivered)
+                        deliveredRate: {
+                            $cond: [
+                                {
+                                    $eq: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                            ],
+                                        },
+                                        0,
+                                    ],
+                                },
+                                0,
+                                {
+                                    $divide: [
+                                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                            ],
+                                        },
+                                    ],
+                                },
+                            ],
+                        },
+                        // Opened rate = opened / (delivered + opened)
+                        openedRate: {
+                            $cond: [
+                                {
+                                    $eq: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                        0,
+                                    ],
+                                },
+                                0,
+                                {
+                                    $divide: [
+                                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                    ],
+                                },
+                            ],
+                        },
+                        // Token invalidation rate
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_records", 0] }, 0, { $divide: [{ $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0] }, "$total_records"] }],
+                        },
+                    },
+                },
+            ]);
+            return result[0];
+        }
+        catch (error) {
+            console.error("Error fetching overall statistics:", error);
+            return null;
+        }
+    }
+    /**
+     * Gets message statistics grouped by activity.
+     */
+    async getStatisticsByActivity() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Group by both activityId and status
+                {
+                    $group: {
+                        _id: { activityId: "$activityId", activityName: "$activityName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. Regroup by activityId to summarize counts and get the activityName
+                {
+                    $group: {
+                        _id: "$_id.activityId", // Group by the ID
+                        activityName: { $first: "$_id.activityName" }, // Keep the name
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. Calculate rates and format the output
+                {
+                    $project: {
+                        _id: 0,
+                        activityId: "$_id",
+                        activityName: "$activityName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. Sort by deliveredRate in descending order
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by activity:", error);
+            return [];
+        }
+    }
+    /**
+     * Gets message statistics grouped by strategy.
+     */
+    async getStatisticsByStrategy() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Group by both strategyId and status
+                {
+                    $group: {
+                        _id: { strategyId: "$strategyId", strategyName: "$strategyName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. Regroup by strategyId to summarize counts and get the strategyName
+                {
+                    $group: {
+                        _id: "$_id.strategyId",
+                        strategyName: { $first: "$_id.strategyName" },
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. Calculate rates and format the output
+                {
+                    $project: {
+                        _id: 0,
+                        strategyId: "$_id",
+                        strategyName: "$strategyName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. Sort by deliveredRate in descending order
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by strategy:", error);
+            return [];
+        }
+    }
+    /**
+     * Gets message statistics grouped by template.
+     */
+    async getStatisticsByTemplate() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Group by both templateId and status
+                {
+                    $group: {
+                        _id: { templateId: "$templateId", templateName: "$templateName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. Regroup by templateId to summarize counts and get the templateName
+                {
+                    $group: {
+                        _id: "$_id.templateId",
+                        templateName: { $first: "$_id.templateName" },
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. Calculate rates and format the output
+                {
+                    $project: {
+                        _id: 0,
+                        templateId: "$_id",
+                        templateName: "$templateName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. Sort by deliveredRate in descending order
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by template:", error);
+            return [];
+        }
+    }
+    /**
+     * Gets daily message sending trends.
+     */
+    async getDailySentTrends() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Truncate actualSendAt to the day, ignoring time
+                {
+                    $project: {
+                        _id: 0,
+                        date: {
+                            $dateTrunc: { date: "$actualSendAt", unit: "day", timezone: "America/Los_Angeles" },
+                        },
+                        status: "$status",
+                    },
+                },
+                // 2. Group by date and status
+                {
+                    $group: {
+                        _id: { date: "$date", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 3. Regroup to summarize counts by date
+                {
+                    $group: {
+                        _id: "$_id.date",
+                        totalSent: { $sum: "$count" },
+                        sent: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered: { $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] } },
+                        opened: { $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] } },
+                        failed: { $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] } },
+                    },
+                },
+                // 4. Calculate rates and format the output
+                {
+                    $project: {
+                        _id: 0,
+                        date: "$_id",
+                        totalSent: "$totalSent",
+                        sent: "$sent",
+                        delivered: "$delivered",
+                        opened: "$opened",
+                        failed: "$failed",
+                        // Sent success rate = (sent + delivered + opened) / total
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: [{ $sum: ["$sent", "$delivered", "$opened"] }, "$totalSent"] }],
+                        },
+                        // Delivered rate = delivered / (sent + delivered)
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent", "$delivered"] }, 0] }, 0, { $divide: ["$delivered", { $sum: ["$sent", "$delivered"] }] }],
+                        },
+                        // Opened rate = opened / (delivered + opened)
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered", "$opened"] }, 0] }, 0, { $divide: ["$opened", { $sum: ["$delivered", "$opened"] }] }],
+                        },
+                        // Token invalidation rate
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: ["$failed", "$totalSent"] }],
+                        },
+                    },
+                },
+                // 5. Sort by date in ascending order
+                {
+                    $sort: { date: 1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching daily sent trends:", error);
+            return [];
+        }
+    }
+    /**
+     * Calculates the average delivery time.
+     */
+    async getAverageDeliveryTime() {
+        try {
+            const result = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. Filter for delivered messages (status 2 or 3) with existing timestamps
+                {
+                    $match: {
+                        status: { $in: [2, 3] },
+                        actualSendAt: { $exists: true, $ne: null },
+                        deliveredAt: { $exists: true, $ne: null },
+                    },
+                },
+                // 2. Calculate the time difference (in milliseconds) from send to delivery
+                {
+                    $addFields: {
+                        time_to_deliver: {
+                            $subtract: ["$deliveredAt", "$actualSendAt"],
+                        },
+                    },
+                },
+                // 3. Calculate the average time
+                {
+                    $group: {
+                        _id: null,
+                        avg_time_to_deliver_ms: { $avg: "$time_to_deliver" },
+                    },
+                },
+                // 4. Convert to seconds for readability
+                {
+                    $project: {
+                        _id: 0,
+                        averageTimeToDeliverInSeconds: {
+                            $divide: ["$avg_time_to_deliver_ms", 1000],
+                        },
+                    },
+                },
+            ]);
+            return result[0];
+        }
+        catch (error) {
+            console.error("Error calculating average delivery time:", error);
+            return null;
+        }
+    }
+}
+exports.MessageRecordService = MessageRecordService;

+ 452 - 0
oms/dist/src/services/messageRecordService.js

@@ -76,5 +76,457 @@ class MessageRecordService {
     async updateMessageRecord(recordId, updateData) {
     async updateMessageRecord(recordId, updateData) {
         return await messageRecordModel_1.MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
         return await messageRecordModel_1.MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
     }
     }
+    /**
+     * 获取整体消息推送统计数据
+     */
+    async getOverallStatistics() {
+        try {
+            const result = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 根据状态对消息进行分类
+                {
+                    $group: {
+                        _id: "$status",
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. 将结果转换成一个更容易处理的格式
+                {
+                    $group: {
+                        _id: null,
+                        total_records: { $sum: "$count" },
+                        status_counts: {
+                            $push: {
+                                k: { $toString: "$_id" },
+                                v: "$count",
+                            },
+                        },
+                    },
+                },
+                // 3. 重新格式化输出,计算所有比率
+                {
+                    $project: {
+                        _id: 0,
+                        totalRecords: "$total_records",
+                        sent: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0],
+                        },
+                        delivered: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0],
+                        },
+                        opened: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0],
+                        },
+                        failed: {
+                            $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0],
+                        },
+                        // 新增:发送成功率(发送成功数 + 送达数 + 打开数)/ 总数
+                        sentSuccessRate: {
+                            $cond: [
+                                { $eq: ["$total_records", 0] },
+                                0,
+                                {
+                                    $divide: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                        "$total_records",
+                                    ],
+                                },
+                            ],
+                        },
+                        // 新增:送达率 = 送达数 / (发送成功数 + 送达数)
+                        deliveredRate: {
+                            $cond: [
+                                {
+                                    $eq: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                            ],
+                                        },
+                                        0,
+                                    ],
+                                },
+                                0,
+                                {
+                                    $divide: [
+                                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                            ],
+                                        },
+                                    ],
+                                },
+                            ],
+                        },
+                        // 新增:打开率 = 打开数 / (送达数 + 打开数)
+                        openedRate: {
+                            $cond: [
+                                {
+                                    $eq: [
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                        0,
+                                    ],
+                                },
+                                0,
+                                {
+                                    $divide: [
+                                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                        {
+                                            $sum: [
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                                                { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                                            ],
+                                        },
+                                    ],
+                                },
+                            ],
+                        },
+                        // Token 失效率
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_records", 0] }, 0, { $divide: [{ $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0] }, "$total_records"] }],
+                        },
+                    },
+                },
+            ]);
+            return result[0];
+        }
+        catch (error) {
+            console.error("Error fetching overall statistics:", error);
+            return null;
+        }
+    }
+    /**
+     * 按活动获取消息统计数据
+     */
+    async getStatisticsByActivity() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 根据 activityId 和 status 进行分组
+                {
+                    $group: {
+                        _id: { activityId: "$activityId", activityName: "$activityName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. 将数据重组,以便按 activityId 汇总
+                {
+                    $group: {
+                        _id: "$_id.activityId", // Group by the ID
+                        activityName: { $first: "$_id.activityName" }, // Keep the name
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. 计算比率并格式化输出
+                {
+                    $project: {
+                        _id: 0,
+                        activityId: "$_id",
+                        activityName: "$activityName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. (可选) 按 deliveredRate 降序排序
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by activity:", error);
+            return [];
+        }
+    }
+    /**
+     * 按策略获取消息统计数据
+     */
+    async getStatisticsByStrategy() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 根据 strategyId 和 status 进行分组
+                {
+                    $group: {
+                        _id: { strategyId: "$strategyId", strategyName: "$strategyName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. 将数据重组,以便按 strategyId 汇总
+                {
+                    $group: {
+                        _id: "$_id.strategyId",
+                        strategyName: { $first: "$_id.strategyName" },
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. 计算比率并格式化输出
+                {
+                    $project: {
+                        _id: 0,
+                        strategyId: "$_id",
+                        strategyName: "$strategyName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. (可选) 按 deliveredRate 降序排序
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by strategy:", error);
+            return [];
+        }
+    }
+    /**
+     * 按模板获取消息统计数据
+     */
+    async getStatisticsByTemplate() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 根据 templateId 和 status 进行分组
+                {
+                    $group: {
+                        _id: { templateId: "$templateId", templateName: "$templateName", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 2. 将数据重组,以便按 templateId 汇总
+                {
+                    $group: {
+                        _id: "$_id.templateId",
+                        templateName: { $first: "$_id.templateName" },
+                        total_sent: { $sum: "$count" },
+                        sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+                        },
+                        opened_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+                        },
+                        failed_count: {
+                            $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+                        },
+                    },
+                },
+                // 3. 计算比率并格式化输出
+                {
+                    $project: {
+                        _id: 0,
+                        templateId: "$_id",
+                        templateName: "$templateName",
+                        totalSent: "$total_sent",
+                        sent: "$sent_count",
+                        delivered: "$delivered_count",
+                        opened: "$opened_count",
+                        failed: "$failed_count",
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+                        },
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+                        },
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+                        },
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+                        },
+                    },
+                },
+                // 4. (可选) 按 deliveredRate 降序排序
+                {
+                    $sort: { deliveredRate: -1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching statistics by template:", error);
+            return [];
+        }
+    }
+    // 按时间维度的趋势分析,每日统计
+    async getDailySentTrends() {
+        try {
+            const results = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 将 actualSendAt 字段转换为日期,忽略时分秒
+                {
+                    $project: {
+                        _id: 0,
+                        date: {
+                            $dateTrunc: { date: "$actualSendAt", unit: "day", timezone: "America/Los_Angeles" },
+                        },
+                        status: "$status",
+                    },
+                },
+                // 2. 根据日期和状态进行分组
+                {
+                    $group: {
+                        _id: { date: "$date", status: "$status" },
+                        count: { $sum: 1 },
+                    },
+                },
+                // 3. 将数据重组,以便按日期汇总
+                {
+                    $group: {
+                        _id: "$_id.date",
+                        totalSent: { $sum: "$count" },
+                        sent: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+                        delivered: { $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] } },
+                        opened: { $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] } },
+                        failed: { $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] } },
+                    },
+                },
+                // 4. 计算比率并格式化输出
+                {
+                    $project: {
+                        _id: 0,
+                        date: "$_id",
+                        totalSent: "$totalSent",
+                        sent: "$sent",
+                        delivered: "$delivered",
+                        opened: "$opened",
+                        failed: "$failed",
+                        // Sent success rate = (sent + delivered + opened) / total
+                        sentSuccessRate: {
+                            $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: [{ $sum: ["$sent", "$delivered", "$opened"] }, "$totalSent"] }],
+                        },
+                        // Delivered rate = delivered / (sent + delivered)
+                        deliveredRate: {
+                            $cond: [{ $eq: [{ $sum: ["$sent", "$delivered"] }, 0] }, 0, { $divide: ["$delivered", { $sum: ["$sent", "$delivered"] }] }],
+                        },
+                        // Opened rate = opened / (delivered + opened)
+                        openedRate: {
+                            $cond: [{ $eq: [{ $sum: ["$delivered", "$opened"] }, 0] }, 0, { $divide: ["$opened", { $sum: ["$delivered", "$opened"] }] }],
+                        },
+                        // Token invalidation rate
+                        tokenInvalidationRate: {
+                            $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: ["$failed", "$totalSent"] }],
+                        },
+                    },
+                },
+                // 5. 按日期升序排序
+                {
+                    $sort: { date: 1 },
+                },
+            ]);
+            return results;
+        }
+        catch (error) {
+            console.error("Error fetching daily sent trends:", error);
+            return [];
+        }
+    }
+    // 消息生命周期分析, 利用 plannedSendAt、actualSendAt、deliveredAt 和 openedAt 字段,可以分析消息从计划到被用户打开的整个生命周期。
+    async getAverageDeliveryTime() {
+        try {
+            const result = await messageRecordModel_1.MessageRecord.aggregate([
+                // 1. 只筛选出已送达的消息(status = 2 或 3)
+                {
+                    $match: {
+                        status: { $in: [2, 3] },
+                        actualSendAt: { $exists: true, $ne: null },
+                        deliveredAt: { $exists: true, $ne: null },
+                    },
+                },
+                // 2. 计算每条消息的发送到送达的时间差(毫秒)
+                {
+                    $addFields: {
+                        time_to_deliver: {
+                            $subtract: ["$deliveredAt", "$actualSendAt"],
+                        },
+                    },
+                },
+                // 3. 计算平均时间
+                {
+                    $group: {
+                        _id: null,
+                        avg_time_to_deliver_ms: { $avg: "$time_to_deliver" },
+                    },
+                },
+                // 4. 转换成秒或分钟以方便阅读
+                {
+                    $project: {
+                        _id: 0,
+                        averageTimeToDeliverInSeconds: {
+                            $divide: ["$avg_time_to_deliver_ms", 1000],
+                        },
+                    },
+                },
+            ]);
+            return result[0];
+        }
+        catch (error) {
+            console.error("Error calculating average delivery time:", error);
+            return null;
+        }
+    }
 }
 }
 exports.MessageRecordService = MessageRecordService;
 exports.MessageRecordService = MessageRecordService;

+ 108 - 0
oms/public/app/3rdpartylicenses.txt

@@ -531,6 +531,114 @@ The above copyright notice and this permission notice shall be included in all c
 
 
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
 
+--------------------------------------------------------------------------------
+Package: @kurkle/color
+License: "MIT"
+
+The MIT License (MIT)
+
+Copyright (c) 2018-2024 Jukka Kurkela
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+--------------------------------------------------------------------------------
+Package: chart.js
+License: "MIT"
+
+The MIT License (MIT)
+
+Copyright (c) 2014-2024 Chart.js Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+--------------------------------------------------------------------------------
+Package: lodash-es
+License: "MIT"
+
+Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
+
+Based on Underscore.js, copyright Jeremy Ashkenas,
+DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/lodash/lodash
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+====
+
+Copyright and related rights for sample code are waived via CC0. Sample
+code is defined as all source code displayed within the prose of the
+documentation.
+
+CC0: http://creativecommons.org/publicdomain/zero/1.0/
+
+====
+
+Files located in the node_modules and vendor directories are externally
+maintained libraries used by this software which have their own
+licenses; we recommend you read them, as their terms may differ from the
+terms above.
+
+--------------------------------------------------------------------------------
+Package: ng2-charts
+License: "MIT"
+
+The MIT License (MIT)
+
+Copyright (c) 2023 Valor Labs
+Copyright (c) 2023 Valor Software
+Copyright (c) 2023 Dmitriy Shekhovtsov<valorkin@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 --------------------------------------------------------------------------------
 --------------------------------------------------------------------------------
 Package: zone.js
 Package: zone.js
 License: "MIT"
 License: "MIT"

+ 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>
   <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>
   <body>
     <app-root></app-root>
     <app-root></app-root>
-  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-SEMN5WYS.js" type="module"></script></body>
+  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-V2NUZO54.js" type="module"></script></body>
 </html>
 </html>

File diff suppressed because it is too large
+ 0 - 0
oms/public/app/main-SEMN5WYS.js


File diff suppressed because it is too large
+ 0 - 0
oms/public/app/main-V2NUZO54.js


+ 312 - 0
oms/services/cron-jobs/active-user-daily-notify.ts

@@ -0,0 +1,312 @@
+import mongoose, { Schema, Document } from "mongoose";
+import { User, IUser } from "../../src/models/userModel";
+
+// 确保先导入并注册 MessageTemplate 模型
+import { IMessageTemplate, MessageTemplate } from "../../src/models/messageTemplateModel";
+// 确保 MessageTemplate 模型已被注册
+mongoose.model("MessageTemplate", MessageTemplate.schema);
+
+// 然后再导入和使用 MessageStrategy 模型
+import { MessageStrategy, IMessageStrategy } from "../../src/models/messageStrategyModel";
+
+import { MessageRecord, IMessageRecord } from "../../src/models/messageRecordModel";
+import { FCMService } from "../../src/services/fcmService";
+
+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.findOneAndUpdate({ uid: uid }, { fmToken: null });
+      console.warn(`[FCM] 检测到无效令牌,自动清除 UID ${uid} 的 fmToken。`);
+      errorMessage += " (Token cleared)";
+    } else {
+      console.error(`发送消息给用户 ${uid} 失败:`, error);
+    }
+  } finally {
+    // 4. 不论成功与否,更新 MessageRecord 的状态和结果
+    if (messageRecord) {
+      await MessageRecord.findByIdAndUpdate(messageRecord._id, {
+        status: messageStatus,
+        fcmReceipt: fcmReceipt as any,
+        actualSendAt: new Date(),
+        errno: errorMessage,
+      });
+    }
+  }
+};
+
+/**
+ * 脚本的入口方法,用于筛选用户并发送每日FCM通知。
+ * 此方法通过cron外部调用。
+ *
+ * @returns {Promise<void>} 返回一个 Promise,当所有任务(包括定时任务)完成后解决。
+ */
+export async function run(): Promise<void> {
+  console.log("脚本开始:发送活跃用户每日通知...");
+
+  try {
+    const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+    // 性能优化:只查询需要的字段(_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"] },
+      versionCode: { $gte: 347 },
+    })
+      .select("_id uid fmToken lang cc")
+      .lean<IUser[]>();
+
+    if (activeUsers.length === 0) {
+      console.log("未找到符合条件的用户,脚本结束。");
+      return;
+    }
+    console.log(`找到 ${activeUsers.length} 位活跃用户。`);
+
+    const strategy = await MessageStrategy.findOne({ name: strategyName }).populate("templates");
+    if (!strategy || !strategy.templates || strategy.templates.length < 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.uid, fcmToken, messageData.template1, data1, strategy._id, strategy.name);
+    }
+    console.log("第一批消息发送完成。");
+
+    // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
+    // return new Promise<void>((resolve) => {
+    //   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.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
+    //     }
+    //     console.log("第二批消息发送完成。");
+
+    //     // 所有任务完成后,安全地断开数据库连接
+    //     await disconnectFromDatabase();
+    //     resolve();
+    //   }, 30 * 60 * 1000);
+    // });
+  } catch (error) {
+    console.error("脚本执行过程中发生致命错误:", error);
+    throw error;
+  }
+}
+
+// 这个 if 块确保只有在直接运行此文件时才调用 run() 函数
+if (require.main === module) {
+  run()
+    .then(() => {
+      console.log("脚本执行完毕,退出进程。");
+      process.exit(0);
+    })
+    .catch((err) => {
+      console.error("脚本执行失败:", err);
+      process.exit(1);
+    });
+}

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

@@ -16,6 +16,7 @@ const settings: [string, string, CronJobModule][] = [
   ["done-rate", "10 0 * * *", require("./done-rate2") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
   ["done-rate", "10 0 * * *", require("./done-rate2") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
   ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次
   ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次
+  ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
 ];
 ];
 
 
 /**
 /**

+ 2 - 5
oms/services/ingestor-service.ts

@@ -264,12 +264,9 @@ async function processMessage(msg: Message) {
 
 
   // --- 1. Handle Message-Specific Events First ---
   // --- 1. Handle Message-Specific Events First ---
   if (["message_receive", "message_open"].includes(eventType)) {
   if (["message_receive", "message_open"].includes(eventType)) {
-    await handleMessageEvent(eventData, eventType);
-    amqpChannel.ack(msg); // Acknowledge message after processing message events
-    return;
-  }
+    await handleMessageEvent(eventData, eventType); // 移除 amqpChannel.ack(msg); 和 return; // 让事件继续向下流转,以便被记录到ClickHouse和更新User表
+  } // Filter by allowed event types
 
 
-  // Filter by allowed event types
   if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
   if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
     // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
     // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
     amqpChannel.ack(msg); // Acknowledge and drop unsupported events
     amqpChannel.ack(msg); // Acknowledge and drop unsupported events

+ 122 - 11
oms/src/controllers/messageRecordController.ts

@@ -1,14 +1,23 @@
+// oms/src/controllers/messageRecordController.ts
+
 import { Request, Response } from "express";
 import { Request, Response } from "express";
-import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
 import { isObjectIdOrHexString } from "mongoose";
 import { isObjectIdOrHexString } from "mongoose";
+import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+import { MessageRecordService } from "../services/messageRecordService";
 
 
 class MessageRecordController {
 class MessageRecordController {
+  private messageRecordService: MessageRecordService;
+
+  constructor() {
+    this.messageRecordService = new MessageRecordService();
+  }
+
   /**
   /**
    * @route POST /api/message-record
    * @route POST /api/message-record
    * @desc Creates a new message record
    * @desc Creates a new message record
    * @access Private
    * @access Private
    */
    */
-  public async createRecord(req: Request, res: Response): Promise<Response> {
+  public createRecord = async (req: Request, res: Response): Promise<Response> => {
     try {
     try {
       const newRecord = new MessageRecord(req.body);
       const newRecord = new MessageRecord(req.body);
       await newRecord.save();
       await newRecord.save();
@@ -17,14 +26,14 @@ class MessageRecordController {
       console.error("Error creating message record:", error);
       console.error("Error creating message record:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
     }
-  }
+  };
 
 
   /**
   /**
    * @route GET /api/message-records
    * @route GET /api/message-records
    * @desc Retrieves all message records with pagination and optional filters
    * @desc Retrieves all message records with pagination and optional filters
    * @access Private
    * @access Private
    */
    */
-  public async getPaginatedRecords(req: Request, res: Response): Promise<Response> {
+  public getPaginatedRecords = async (req: Request, res: Response): Promise<Response> => {
     const { page = 1, limit = 30, uid, activityName, templateName, strategyName, 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 pageNum = parseInt(page as string, 10);
@@ -43,6 +52,18 @@ class MessageRecordController {
       }
       }
     }
     }
 
 
+    if (activityName) {
+      filters.activityName = activityName;
+    }
+
+    if (strategyName) {
+      filters.strategyName = strategyName;
+    }
+
+    if (templateName) {
+      filters.templateName = templateName;
+    }
+
     // 定义所有可查询的日期字段
     // 定义所有可查询的日期字段
     const dateQueryKeys: (keyof IMessageRecord)[] = ["plannedSendAt", "actualSendAt", "deliveredAt", "openedAt", "createdAt", "updatedAt"];
     const dateQueryKeys: (keyof IMessageRecord)[] = ["plannedSendAt", "actualSendAt", "deliveredAt", "openedAt", "createdAt", "updatedAt"];
 
 
@@ -110,14 +131,14 @@ class MessageRecordController {
       console.error("Error fetching paginated records:", error);
       console.error("Error fetching paginated records:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
     }
-  }
+  };
 
 
   /**
   /**
    * @route GET /api/message-records/user/:uid
    * @route GET /api/message-records/user/:uid
    * @desc Retrieves message records by user UID
    * @desc Retrieves message records by user UID
    * @access Private
    * @access Private
    */
    */
-  public async getRecordsByUid(req: Request, res: Response): Promise<Response> {
+  public getRecordsByUid = async (req: Request, res: Response): Promise<Response> => {
     try {
     try {
       const records = await MessageRecord.find({ uid: req.params.uid }).sort({ createdAt: -1 });
       const records = await MessageRecord.find({ uid: req.params.uid }).sort({ createdAt: -1 });
       if (!records || records.length === 0) {
       if (!records || records.length === 0) {
@@ -128,14 +149,14 @@ class MessageRecordController {
       console.error("Error fetching records by user UID:", error);
       console.error("Error fetching records by user UID:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
     }
-  }
+  };
 
 
   /**
   /**
    * @route GET /api/message-record/:id
    * @route GET /api/message-record/:id
    * @desc Retrieves a single message record by ID
    * @desc Retrieves a single message record by ID
    * @access Private
    * @access Private
    */
    */
-  public async getRecordById(req: Request, res: Response): Promise<Response> {
+  public getRecordById = async (req: Request, res: Response): Promise<Response> => {
     try {
     try {
       // 检查 id 是否是有效的 ObjectId 格式
       // 检查 id 是否是有效的 ObjectId 格式
       if (!isObjectIdOrHexString(req.params.id)) {
       if (!isObjectIdOrHexString(req.params.id)) {
@@ -150,14 +171,14 @@ class MessageRecordController {
       console.error("Error fetching message record by ID:", error);
       console.error("Error fetching message record by ID:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
     }
-  }
+  };
 
 
   /**
   /**
    * @route PUT /api/message-record/:id
    * @route PUT /api/message-record/:id
    * @desc Updates the status of a message record
    * @desc Updates the status of a message record
    * @access Private
    * @access Private
    */
    */
-  public async updateRecord(req: Request, res: Response): Promise<Response> {
+  public updateRecord = async (req: Request, res: Response): Promise<Response> => {
     try {
     try {
       // 检查 id 是否是有效的 ObjectId 格式
       // 检查 id 是否是有效的 ObjectId 格式
       if (!isObjectIdOrHexString(req.params.id)) {
       if (!isObjectIdOrHexString(req.params.id)) {
@@ -172,7 +193,97 @@ class MessageRecordController {
       console.error("Error updating message record:", error);
       console.error("Error updating message record:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
     }
-  }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/overall
+   * @desc Retrieves overall message push statistics
+   * @access Private
+   */
+  public getOverallStatistics = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getOverallStatistics();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching overall statistics:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/by-activity
+   * @desc Retrieves message push statistics grouped by activity
+   * @access Private
+   */
+  public getStatisticsByActivity = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getStatisticsByActivity();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching statistics by activity:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/by-strategy
+   * @desc Retrieves message push statistics grouped by strategy
+   * @access Private
+   */
+  public getStatisticsByStrategy = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getStatisticsByStrategy();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching statistics by strategy:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/by-template
+   * @desc Retrieves message push statistics grouped by template
+   * @access Private
+   */
+  public getStatisticsByTemplate = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getStatisticsByTemplate();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching statistics by strategy:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/daily-trends
+   * @desc Retrieves daily sent trend statistics
+   * @access Private
+   */
+  public getDailySentTrends = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getDailySentTrends();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching daily sent trends:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
+  /**
+   * @route GET /api/message-records/statistics/avg-delivery-time
+   * @desc Retrieves average message delivery time
+   * @access Private
+   */
+  public getAverageDeliveryTime = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const stats = await this.messageRecordService.getAverageDeliveryTime();
+      return res.status(200).json({ success: true, data: stats });
+    } catch (error: any) {
+      console.error("Error fetching average delivery time:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
 }
 }
 
 
 export default new MessageRecordController();
 export default new MessageRecordController();

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

@@ -72,6 +72,14 @@ router.get("/message-records/user/:uid", messageRecordController.getRecordsByUid
 router.get("/message-record/:id", messageRecordController.getRecordById);
 router.get("/message-record/:id", messageRecordController.getRecordById);
 router.put("/message-record/:id", messageRecordController.updateRecord);
 router.put("/message-record/:id", messageRecordController.updateRecord);
 
 
+// 新增:消息记录统计路由
+router.get("/message-records/statistics/overall", messageRecordController.getOverallStatistics);
+router.get("/message-records/statistics/by-activity", messageRecordController.getStatisticsByActivity);
+router.get("/message-records/statistics/by-strategy", messageRecordController.getStatisticsByStrategy);
+router.get("/message-records/statistics/by-template", messageRecordController.getStatisticsByTemplate);
+router.get("/message-records/statistics/daily-trends", messageRecordController.getDailySentTrends);
+router.get("/message-records/statistics/avg-delivery-time", messageRecordController.getAverageDeliveryTime);
+
 // 管理员路由
 // 管理员路由
 router.get("/admin", adminController.getAdmins);
 router.get("/admin", adminController.getAdmins);
 router.get("/admin/:id", adminController.getAdminById);
 router.get("/admin/:id", adminController.getAdminById);

+ 27 - 25
oms/src/scripts/active-user-daily-notify.ts.ts → oms/src/scripts/active-user-daily-notify.ts

@@ -11,7 +11,7 @@ import { MessageStrategy, IMessageStrategy } from "../models/messageStrategyMode
 
 
 import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
 import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
 import { FCMService } from "../services/fcmService";
 import { FCMService } from "../services/fcmService";
-import { connectToDatabase, disconnectFromDatabase } from "../../src/database";
+import { connectToDatabase, disconnectFromDatabase } from "../database";
 
 
 const strategyName = "active_new_content_notify";
 const strategyName = "active_new_content_notify";
 const fcmService = FCMService.getInstance();
 const fcmService = FCMService.getInstance();
@@ -212,7 +212,8 @@ export async function run(): Promise<void> {
       lastActiveAt: { $gte: sevenDaysAgo },
       lastActiveAt: { $gte: sevenDaysAgo },
       fmToken: { $nin: [null, ""] },
       fmToken: { $nin: [null, ""] },
       //   versionName: { $in: ["5.8.0-debug"] },
       //   versionName: { $in: ["5.8.0-debug"] },
-      versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      // versionName: { $in: ["5.8.0", "5.8.0-debug"] },
+      versionCode: { $gte: 347 },
     })
     })
       .select("_id uid fmToken lang cc")
       .select("_id uid fmToken lang cc")
       .lean<IUser[]>();
       .lean<IUser[]>();
@@ -275,29 +276,30 @@ export async function run(): Promise<void> {
     console.log("第一批消息发送完成。");
     console.log("第一批消息发送完成。");
 
 
     // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
     // 返回一个 Promise,该 Promise 将在 30 分钟后执行并完成所有后续操作
-    return new Promise<void>((resolve) => {
-      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.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
-        }
-        console.log("第二批消息发送完成。");
-
-        // 所有任务完成后,安全地断开数据库连接
-        await disconnectFromDatabase();
-        resolve();
-      }, 30 * 60 * 1000);
-    });
+    await disconnectFromDatabase(); // 先注释掉先不发第二轮消息了
+    // return new Promise<void>((resolve) => {
+    //   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.uid, fcmToken, messageData.template2, data2, strategy._id, strategy.name);
+    //     }
+    //     console.log("第二批消息发送完成。");
+
+    //     // 所有任务完成后,安全地断开数据库连接
+    //     await disconnectFromDatabase();
+    //     resolve();
+    //   }, 30 * 60 * 1000);
+    // });
   } catch (error) {
   } catch (error) {
     console.error("脚本执行过程中发生致命错误:", error);
     console.error("脚本执行过程中发生致命错误:", error);
     // 如果在第一阶段发生错误,确保断开数据库连接
     // 如果在第一阶段发生错误,确保断开数据库连接

+ 559 - 0
oms/src/services/messageRecordService copy.ts

@@ -0,0 +1,559 @@
+// oms/src/services/messageRecordService.ts
+
+import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+import { MessageActivity } from "../models/messageActivityModel";
+import { MessageStrategy } from "../models/messageStrategyModel";
+import { MessageTemplate } from "../models/messageTemplateModel";
+
+export class MessageRecordService {
+  /**
+   * Creates a new message push record.
+   * @param recordData Message record data.
+   * @returns The newly created message record object.
+   */
+  public async createMessageRecord(recordData: IMessageRecord): Promise<IMessageRecord> {
+    const newRecord = new MessageRecord(recordData);
+    return await newRecord.save();
+  }
+
+  /**
+   * Gets paginated message records with filtering and sorting support.
+   * @param page Page number.
+   * @param limit Number of records per page.
+   * @param filters Filter conditions.
+   * @param sortField Field to sort by.
+   * @param sortOrder Sort order ('asc' or 'desc').
+   * @returns An object containing records and the total count.
+   */
+  public async getPaginatedRecords(
+    page: number = 1,
+    limit: number = 10,
+    filters: { [key: string]: any } = {},
+    sortField: string = "createdAt",
+    sortOrder: "asc" | "desc" = "desc"
+  ): Promise<{ records: IMessageRecord[]; total: number }> {
+    // Build the query conditions
+    const query: any = {};
+    if (filters.uid) {
+      query.uid = filters.uid;
+    }
+    if (filters.activityName) {
+      query.activityName = filters.activityName;
+    }
+    if (filters.templateName) {
+      query.templateName = filters.templateName;
+    }
+    if (filters.status !== undefined) {
+      query.status = filters.status;
+    }
+
+    const sort: any = {};
+    sort[sortField] = sortOrder === "asc" ? 1 : -1;
+
+    const skip = (page - 1) * limit;
+
+    const records = await MessageRecord.find(query).sort(sort).skip(skip).limit(limit);
+
+    const total = await MessageRecord.countDocuments(query);
+
+    return { records, total };
+  }
+
+  /**
+   * Gets all message records for a specific user UID.
+   * @param uid User UID.
+   * @returns A list of message records.
+   */
+  public async getRecordsByUid(uid: string): Promise<IMessageRecord[]> {
+    return await MessageRecord.find({ uid }).sort({ createdAt: -1 });
+  }
+
+  /**
+   * Gets all message records related to a specific activity ID.
+   * @param activityId Message activity ID.
+   * @returns A list of message records.
+   */
+  public async getRecordsByActivityId(activityId: string): Promise<IMessageRecord[]> {
+    return await MessageRecord.find({ activityId }).sort({ createdAt: -1 });
+  }
+
+  /**
+   * Gets a single message record by ID.
+   * @param recordId Message record ID.
+   * @returns The message record object or null.
+   */
+  public async getSingleRecord(recordId: string): Promise<IMessageRecord | null> {
+    return await MessageRecord.findById(recordId);
+  }
+
+  /**
+   * Updates the status of a message record.
+   * @param recordId Message record ID.
+   * @param updateData Data to update.
+   * @returns The updated message record object.
+   */
+  public async updateMessageRecord(recordId: string, updateData: Partial<IMessageRecord>): Promise<IMessageRecord | null> {
+    return await MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
+  }
+
+  /**
+   * Gets overall message push statistics.
+   */
+  public async getOverallStatistics() {
+    try {
+      const result = await MessageRecord.aggregate([
+        // 1. Group messages by status
+        {
+          $group: {
+            _id: "$status",
+            count: { $sum: 1 },
+          },
+        },
+        // 2. Transform the result into a more manageable format
+        {
+          $group: {
+            _id: null,
+            total_records: { $sum: "$count" },
+            status_counts: {
+              $push: {
+                k: { $toString: "$_id" },
+                v: "$count",
+              },
+            },
+          },
+        },
+        // 3. Reformat the output and calculate all rates
+        {
+          $project: {
+            _id: 0,
+            totalRecords: "$total_records",
+            sent: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0],
+            },
+            delivered: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0],
+            },
+            opened: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0],
+            },
+            failed: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0],
+            },
+            // Sent success rate = (sent + delivered + opened) / total
+            sentSuccessRate: {
+              $cond: [
+                { $eq: ["$total_records", 0] },
+                0,
+                {
+                  $divide: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                    "$total_records",
+                  ],
+                },
+              ],
+            },
+            // Delivered rate = delivered / (sent + delivered)
+            deliveredRate: {
+              $cond: [
+                {
+                  $eq: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                      ],
+                    },
+                    0,
+                  ],
+                },
+                0,
+                {
+                  $divide: [
+                    { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                      ],
+                    },
+                  ],
+                },
+              ],
+            },
+            // Opened rate = opened / (delivered + opened)
+            openedRate: {
+              $cond: [
+                {
+                  $eq: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                    0,
+                  ],
+                },
+                0,
+                {
+                  $divide: [
+                    { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                  ],
+                },
+              ],
+            },
+            // Token invalidation rate
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_records", 0] }, 0, { $divide: [{ $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0] }, "$total_records"] }],
+            },
+          },
+        },
+      ]);
+
+      return result[0];
+    } catch (error) {
+      console.error("Error fetching overall statistics:", error);
+      return null;
+    }
+  }
+
+  /**
+   * Gets message statistics grouped by activity.
+   */
+  public async getStatisticsByActivity() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. Group by both activityId and status
+        {
+          $group: {
+            _id: { activityId: "$activityId", activityName: "$activityName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. Regroup by activityId to summarize counts and get the activityName
+        {
+          $group: {
+            _id: "$_id.activityId", // Group by the ID
+            activityName: { $first: "$_id.activityName" }, // Keep the name
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. Calculate rates and format the output
+        {
+          $project: {
+            _id: 0,
+            activityId: "$_id",
+            activityName: "$activityName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. Sort by deliveredRate in descending order
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by activity:", error);
+      return [];
+    }
+  }
+
+  /**
+   * Gets message statistics grouped by strategy.
+   */
+  public async getStatisticsByStrategy() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. Group by both strategyId and status
+        {
+          $group: {
+            _id: { strategyId: "$strategyId", strategyName: "$strategyName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. Regroup by strategyId to summarize counts and get the strategyName
+        {
+          $group: {
+            _id: "$_id.strategyId",
+            strategyName: { $first: "$_id.strategyName" },
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. Calculate rates and format the output
+        {
+          $project: {
+            _id: 0,
+            strategyId: "$_id",
+            strategyName: "$strategyName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. Sort by deliveredRate in descending order
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by strategy:", error);
+      return [];
+    }
+  }
+
+  /**
+   * Gets message statistics grouped by template.
+   */
+  public async getStatisticsByTemplate() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. Group by both templateId and status
+        {
+          $group: {
+            _id: { templateId: "$templateId", templateName: "$templateName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. Regroup by templateId to summarize counts and get the templateName
+        {
+          $group: {
+            _id: "$_id.templateId",
+            templateName: { $first: "$_id.templateName" },
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. Calculate rates and format the output
+        {
+          $project: {
+            _id: 0,
+            templateId: "$_id",
+            templateName: "$templateName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. Sort by deliveredRate in descending order
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by template:", error);
+      return [];
+    }
+  }
+
+  /**
+   * Gets daily message sending trends.
+   */
+  public async getDailySentTrends() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. Truncate actualSendAt to the day, ignoring time
+        {
+          $project: {
+            _id: 0,
+            date: {
+              $dateTrunc: { date: "$actualSendAt", unit: "day", timezone: "America/Los_Angeles" },
+            },
+            status: "$status",
+          },
+        },
+        // 2. Group by date and status
+        {
+          $group: {
+            _id: { date: "$date", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 3. Regroup to summarize counts by date
+        {
+          $group: {
+            _id: "$_id.date",
+            totalSent: { $sum: "$count" },
+            sent: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered: { $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] } },
+            opened: { $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] } },
+            failed: { $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] } },
+          },
+        },
+        // 4. Calculate rates and format the output
+        {
+          $project: {
+            _id: 0,
+            date: "$_id",
+            totalSent: "$totalSent",
+            sent: "$sent",
+            delivered: "$delivered",
+            opened: "$opened",
+            failed: "$failed",
+            // Sent success rate = (sent + delivered + opened) / total
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: [{ $sum: ["$sent", "$delivered", "$opened"] }, "$totalSent"] }],
+            },
+            // Delivered rate = delivered / (sent + delivered)
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent", "$delivered"] }, 0] }, 0, { $divide: ["$delivered", { $sum: ["$sent", "$delivered"] }] }],
+            },
+            // Opened rate = opened / (delivered + opened)
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered", "$opened"] }, 0] }, 0, { $divide: ["$opened", { $sum: ["$delivered", "$opened"] }] }],
+            },
+            // Token invalidation rate
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: ["$failed", "$totalSent"] }],
+            },
+          },
+        },
+        // 5. Sort by date in ascending order
+        {
+          $sort: { date: 1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching daily sent trends:", error);
+      return [];
+    }
+  }
+
+  /**
+   * Calculates the average delivery time.
+   */
+  public async getAverageDeliveryTime() {
+    try {
+      const result = await MessageRecord.aggregate([
+        // 1. Filter for delivered messages (status 2 or 3) with existing timestamps
+        {
+          $match: {
+            status: { $in: [2, 3] },
+            actualSendAt: { $exists: true, $ne: null },
+            deliveredAt: { $exists: true, $ne: null },
+          },
+        },
+        // 2. Calculate the time difference (in milliseconds) from send to delivery
+        {
+          $addFields: {
+            time_to_deliver: {
+              $subtract: ["$deliveredAt", "$actualSendAt"],
+            },
+          },
+        },
+        // 3. Calculate the average time
+        {
+          $group: {
+            _id: null,
+            avg_time_to_deliver_ms: { $avg: "$time_to_deliver" },
+          },
+        },
+        // 4. Convert to seconds for readability
+        {
+          $project: {
+            _id: 0,
+            averageTimeToDeliverInSeconds: {
+              $divide: ["$avg_time_to_deliver_ms", 1000],
+            },
+          },
+        },
+      ]);
+      return result[0];
+    } catch (error) {
+      console.error("Error calculating average delivery time:", error);
+      return null;
+    }
+  }
+}

+ 457 - 0
oms/src/services/messageRecordService.ts

@@ -90,4 +90,461 @@ export class MessageRecordService {
   public async updateMessageRecord(recordId: string, updateData: Partial<IMessageRecord>): Promise<IMessageRecord | null> {
   public async updateMessageRecord(recordId: string, updateData: Partial<IMessageRecord>): Promise<IMessageRecord | null> {
     return await MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
     return await MessageRecord.findByIdAndUpdate(recordId, updateData, { new: true });
   }
   }
+
+  /**
+   * 获取整体消息推送统计数据
+   */
+  public async getOverallStatistics() {
+    try {
+      const result = await MessageRecord.aggregate([
+        // 1. 根据状态对消息进行分类
+        {
+          $group: {
+            _id: "$status",
+            count: { $sum: 1 },
+          },
+        },
+        // 2. 将结果转换成一个更容易处理的格式
+        {
+          $group: {
+            _id: null,
+            total_records: { $sum: "$count" },
+            status_counts: {
+              $push: {
+                k: { $toString: "$_id" },
+                v: "$count",
+              },
+            },
+          },
+        },
+        // 3. 重新格式化输出,计算所有比率
+        {
+          $project: {
+            _id: 0,
+            totalRecords: "$total_records",
+            sent: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0],
+            },
+            delivered: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0],
+            },
+            opened: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0],
+            },
+            failed: {
+              $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0],
+            },
+            // 新增:发送成功率(发送成功数 + 送达数 + 打开数)/ 总数
+            sentSuccessRate: {
+              $cond: [
+                { $eq: ["$total_records", 0] },
+                0,
+                {
+                  $divide: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                    "$total_records",
+                  ],
+                },
+              ],
+            },
+            // 新增:送达率 = 送达数 / (发送成功数 + 送达数)
+            deliveredRate: {
+              $cond: [
+                {
+                  $eq: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                      ],
+                    },
+                    0,
+                  ],
+                },
+                0,
+                {
+                  $divide: [
+                    { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "1"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                      ],
+                    },
+                  ],
+                },
+              ],
+            },
+            // 新增:打开率 = 打开数 / (送达数 + 打开数)
+            openedRate: {
+              $cond: [
+                {
+                  $eq: [
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                    0,
+                  ],
+                },
+                0,
+                {
+                  $divide: [
+                    { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                    {
+                      $sum: [
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "2"] }] }, 0] },
+                        { $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "3"] }] }, 0] },
+                      ],
+                    },
+                  ],
+                },
+              ],
+            },
+            // Token 失效率
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_records", 0] }, 0, { $divide: [{ $ifNull: [{ $arrayElemAt: ["$status_counts.v", { $indexOfArray: ["$status_counts.k", "-1"] }] }, 0] }, "$total_records"] }],
+            },
+          },
+        },
+      ]);
+
+      return result[0];
+    } catch (error) {
+      console.error("Error fetching overall statistics:", error);
+      return null;
+    }
+  }
+
+  /**
+   * 按活动获取消息统计数据
+   */
+  public async getStatisticsByActivity() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. 根据 activityId 和 status 进行分组
+        {
+          $group: {
+            _id: { activityId: "$activityId", activityName: "$activityName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. 将数据重组,以便按 activityId 汇总
+        {
+          $group: {
+            _id: "$_id.activityId", // Group by the ID
+            activityName: { $first: "$_id.activityName" }, // Keep the name
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. 计算比率并格式化输出
+        {
+          $project: {
+            _id: 0,
+            activityId: "$_id",
+            activityName: "$activityName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. (可选) 按 deliveredRate 降序排序
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by activity:", error);
+      return [];
+    }
+  }
+
+  /**
+   * 按策略获取消息统计数据
+   */
+  public async getStatisticsByStrategy() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. 根据 strategyId 和 status 进行分组
+        {
+          $group: {
+            _id: { strategyId: "$strategyId", strategyName: "$strategyName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. 将数据重组,以便按 strategyId 汇总
+        {
+          $group: {
+            _id: "$_id.strategyId",
+            strategyName: { $first: "$_id.strategyName" },
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. 计算比率并格式化输出
+        {
+          $project: {
+            _id: 0,
+            strategyId: "$_id",
+            strategyName: "$strategyName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. (可选) 按 deliveredRate 降序排序
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by strategy:", error);
+      return [];
+    }
+  }
+
+  /**
+   * 按模板获取消息统计数据
+   */
+  public async getStatisticsByTemplate() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. 根据 templateId 和 status 进行分组
+        {
+          $group: {
+            _id: { templateId: "$templateId", templateName: "$templateName", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 2. 将数据重组,以便按 templateId 汇总
+        {
+          $group: {
+            _id: "$_id.templateId",
+            templateName: { $first: "$_id.templateName" },
+            total_sent: { $sum: "$count" },
+            sent_count: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] },
+            },
+            opened_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] },
+            },
+            failed_count: {
+              $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] },
+            },
+          },
+        },
+        // 3. 计算比率并格式化输出
+        {
+          $project: {
+            _id: 0,
+            templateId: "$_id",
+            templateName: "$templateName",
+            totalSent: "$total_sent",
+            sent: "$sent_count",
+            delivered: "$delivered_count",
+            opened: "$opened_count",
+            failed: "$failed_count",
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: [{ $sum: ["$sent_count", "$delivered_count", "$opened_count"] }, "$total_sent"] }],
+            },
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$total_sent", 0] }, 0, { $divide: ["$failed_count", "$total_sent"] }],
+            },
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent_count", "$delivered_count"] }, 0] }, 0, { $divide: ["$delivered_count", { $sum: ["$sent_count", "$delivered_count"] }] }],
+            },
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered_count", "$opened_count"] }, 0] }, 0, { $divide: ["$opened_count", { $sum: ["$delivered_count", "$opened_count"] }] }],
+            },
+          },
+        },
+        // 4. (可选) 按 deliveredRate 降序排序
+        {
+          $sort: { deliveredRate: -1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching statistics by template:", error);
+      return [];
+    }
+  }
+
+  // 按时间维度的趋势分析,每日统计
+  public async getDailySentTrends() {
+    try {
+      const results = await MessageRecord.aggregate([
+        // 1. 将 actualSendAt 字段转换为日期,忽略时分秒
+        {
+          $project: {
+            _id: 0,
+            date: {
+              $dateTrunc: { date: "$actualSendAt", unit: "day", timezone: "America/Los_Angeles" },
+            },
+            status: "$status",
+          },
+        },
+        // 2. 根据日期和状态进行分组
+        {
+          $group: {
+            _id: { date: "$date", status: "$status" },
+            count: { $sum: 1 },
+          },
+        },
+        // 3. 将数据重组,以便按日期汇总
+        {
+          $group: {
+            _id: "$_id.date",
+            totalSent: { $sum: "$count" },
+            sent: { $sum: { $cond: [{ $eq: ["$_id.status", 1] }, "$count", 0] } },
+            delivered: { $sum: { $cond: [{ $eq: ["$_id.status", 2] }, "$count", 0] } },
+            opened: { $sum: { $cond: [{ $eq: ["$_id.status", 3] }, "$count", 0] } },
+            failed: { $sum: { $cond: [{ $eq: ["$_id.status", -1] }, "$count", 0] } },
+          },
+        },
+        // 4. 计算比率并格式化输出
+        {
+          $project: {
+            _id: 0,
+            date: "$_id",
+            totalSent: "$totalSent",
+            sent: "$sent",
+            delivered: "$delivered",
+            opened: "$opened",
+            failed: "$failed",
+            // Sent success rate = (sent + delivered + opened) / total
+            sentSuccessRate: {
+              $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: [{ $sum: ["$sent", "$delivered", "$opened"] }, "$totalSent"] }],
+            },
+            // Delivered rate = delivered / (sent + delivered)
+            deliveredRate: {
+              $cond: [{ $eq: [{ $sum: ["$sent", "$delivered"] }, 0] }, 0, { $divide: ["$delivered", { $sum: ["$sent", "$delivered"] }] }],
+            },
+            // Opened rate = opened / (delivered + opened)
+            openedRate: {
+              $cond: [{ $eq: [{ $sum: ["$delivered", "$opened"] }, 0] }, 0, { $divide: ["$opened", { $sum: ["$delivered", "$opened"] }] }],
+            },
+            // Token invalidation rate
+            tokenInvalidationRate: {
+              $cond: [{ $eq: ["$totalSent", 0] }, 0, { $divide: ["$failed", "$totalSent"] }],
+            },
+          },
+        },
+        // 5. 按日期升序排序
+        {
+          $sort: { date: 1 },
+        },
+      ]);
+
+      return results;
+    } catch (error) {
+      console.error("Error fetching daily sent trends:", error);
+      return [];
+    }
+  }
+
+  // 消息生命周期分析, 利用 plannedSendAt、actualSendAt、deliveredAt 和 openedAt 字段,可以分析消息从计划到被用户打开的整个生命周期。
+  public async getAverageDeliveryTime() {
+    try {
+      const result = await MessageRecord.aggregate([
+        // 1. 只筛选出已送达的消息(status = 2 或 3)
+        {
+          $match: {
+            status: { $in: [2, 3] },
+            actualSendAt: { $exists: true, $ne: null },
+            deliveredAt: { $exists: true, $ne: null },
+          },
+        },
+        // 2. 计算每条消息的发送到送达的时间差(毫秒)
+        {
+          $addFields: {
+            time_to_deliver: {
+              $subtract: ["$deliveredAt", "$actualSendAt"],
+            },
+          },
+        },
+        // 3. 计算平均时间
+        {
+          $group: {
+            _id: null,
+            avg_time_to_deliver_ms: { $avg: "$time_to_deliver" },
+          },
+        },
+        // 4. 转换成秒或分钟以方便阅读
+        {
+          $project: {
+            _id: 0,
+            averageTimeToDeliverInSeconds: {
+              $divide: ["$avg_time_to_deliver_ms", 1000],
+            },
+          },
+        },
+      ]);
+      return result[0];
+    } catch (error) {
+      console.error("Error calculating average delivery time:", error);
+      return null;
+    }
+  }
 }
 }

+ 65 - 0
omsapp/package-lock.json

@@ -14,7 +14,9 @@
         "@angular/forms": "^20.1.0",
         "@angular/forms": "^20.1.0",
         "@angular/platform-browser": "^20.1.0",
         "@angular/platform-browser": "^20.1.0",
         "@angular/router": "^20.1.0",
         "@angular/router": "^20.1.0",
+        "chart.js": "^4.5.0",
         "ng-zorro-antd": "^20.1.0",
         "ng-zorro-antd": "^20.1.0",
+        "ng2-charts": "^8.0.0",
         "rxjs": "~7.8.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.15.0"
         "zone.js": "~0.15.0"
@@ -23,6 +25,7 @@
         "@angular/build": "^20.1.2",
         "@angular/build": "^20.1.2",
         "@angular/cli": "^20.1.2",
         "@angular/cli": "^20.1.2",
         "@angular/compiler-cli": "^20.1.0",
         "@angular/compiler-cli": "^20.1.0",
+        "@types/chart.js": "^2.9.41",
         "@types/jasmine": "~5.1.0",
         "@types/jasmine": "~5.1.0",
         "jasmine-core": "~5.8.0",
         "jasmine-core": "~5.8.0",
         "karma": "~6.4.0",
         "karma": "~6.4.0",
@@ -1909,6 +1912,12 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
       }
     },
     },
+    "node_modules/@kurkle/color": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+      "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+      "license": "MIT"
+    },
     "node_modules/@listr2/prompt-adapter-inquirer": {
     "node_modules/@listr2/prompt-adapter-inquirer": {
       "version": "2.0.22",
       "version": "2.0.22",
       "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz",
       "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz",
@@ -3536,6 +3545,16 @@
         "url": "https://github.com/sponsors/isaacs"
         "url": "https://github.com/sponsors/isaacs"
       }
       }
     },
     },
+    "node_modules/@types/chart.js": {
+      "version": "2.9.41",
+      "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",
+      "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "moment": "^2.10.2"
+      }
+    },
     "node_modules/@types/cors": {
     "node_modules/@types/cors": {
       "version": "2.8.19",
       "version": "2.8.19",
       "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
       "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -4109,6 +4128,18 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/chart.js": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+      "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@kurkle/color": "^0.3.0"
+      },
+      "engines": {
+        "pnpm": ">=8"
+      }
+    },
     "node_modules/chokidar": {
     "node_modules/chokidar": {
       "version": "4.0.3",
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -6592,6 +6623,12 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+      "license": "MIT"
+    },
     "node_modules/log-symbols": {
     "node_modules/log-symbols": {
       "version": "6.0.0",
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
@@ -7085,6 +7122,16 @@
         "mkdirp": "bin/cmd.js"
         "mkdirp": "bin/cmd.js"
       }
       }
     },
     },
+    "node_modules/moment": {
+      "version": "2.30.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+      "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/mrmime": {
     "node_modules/mrmime": {
       "version": "2.0.1",
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -7196,6 +7243,24 @@
         "@angular/router": "^20.0.0"
         "@angular/router": "^20.0.0"
       }
       }
     },
     },
+    "node_modules/ng2-charts": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-8.0.0.tgz",
+      "integrity": "sha512-nofsNHI2Zt+EAwT+BJBVg0kgOhNo9ukO4CxULlaIi7VwZSr7I1km38kWSoU41Oq6os6qqIh5srnL+CcV+RFPFA==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash-es": "^4.17.15",
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/cdk": ">=19.0.0",
+        "@angular/common": ">=19.0.0",
+        "@angular/core": ">=19.0.0",
+        "@angular/platform-browser": ">=19.0.0",
+        "chart.js": "^3.4.0 || ^4.0.0",
+        "rxjs": "^6.5.3 || ^7.4.0"
+      }
+    },
     "node_modules/node-addon-api": {
     "node_modules/node-addon-api": {
       "version": "6.1.0",
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

+ 3 - 0
omsapp/package.json

@@ -26,7 +26,9 @@
     "@angular/forms": "^20.1.0",
     "@angular/forms": "^20.1.0",
     "@angular/platform-browser": "^20.1.0",
     "@angular/platform-browser": "^20.1.0",
     "@angular/router": "^20.1.0",
     "@angular/router": "^20.1.0",
+    "chart.js": "^4.5.0",
     "ng-zorro-antd": "^20.1.0",
     "ng-zorro-antd": "^20.1.0",
+    "ng2-charts": "^8.0.0",
     "rxjs": "~7.8.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"
     "zone.js": "~0.15.0"
@@ -35,6 +37,7 @@
     "@angular/build": "^20.1.2",
     "@angular/build": "^20.1.2",
     "@angular/cli": "^20.1.2",
     "@angular/cli": "^20.1.2",
     "@angular/compiler-cli": "^20.1.0",
     "@angular/compiler-cli": "^20.1.0",
+    "@types/chart.js": "^2.9.41",
     "@types/jasmine": "~5.1.0",
     "@types/jasmine": "~5.1.0",
     "jasmine-core": "~5.8.0",
     "jasmine-core": "~5.8.0",
     "karma": "~6.4.0",
     "karma": "~6.4.0",

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

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

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

@@ -106,6 +106,15 @@ interface TabItem {
                   <span nz-icon nzType="ant-design"></span>
                   <span nz-icon nzType="ant-design"></span>
                   <span>推送策略</span>
                   <span>推送策略</span>
                 </li>
                 </li>
+                <li
+                  nz-menu-item
+                  [routerLink]="['/message-statistics']"
+                  [routerLinkActive]="['ant-menu-item-selected']"
+                  [nzSelected]="activePath === '/message-statistics'"
+                >
+                  <span nz-icon nzType="line-chart"></span>
+                  <span>统计分析</span>
+                </li>
               </ul>
               </ul>
             </li>
             </li>
           </ul>
           </ul>
@@ -412,6 +421,7 @@ export class MainLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
     '/message-record': '推送记录',
     '/message-record': '推送记录',
     '/message-template': '消息模板',
     '/message-template': '消息模板',
     '/message-strategy': '推送策略',
     '/message-strategy': '推送策略',
+    '/message-statistics': '统计分析',
   };
   };
 
 
   constructor(private router: Router, private authService: AuthService) {}
   constructor(private router: Router, private authService: AuthService) {}

+ 29 - 0
omsapp/src/app/pages/message-dashboard.component.css

@@ -0,0 +1,29 @@
+.filter-controls {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+nz-statistic {
+  text-align: center;
+}
+
+nz-progress {
+  margin-right: 8px;
+  display: inline-block;
+  width: 100px;
+}
+
+nz-card {
+  margin-bottom: 16px;
+}
+
+.chart-container {
+  position: relative;
+  height: 100%;
+  width: 100%;
+}
+
+nz-table {
+  margin-top: 16px;
+}

+ 219 - 0
omsapp/src/app/pages/message-dashboard.component.html

@@ -0,0 +1,219 @@
+<nz-card nzTitle="消息推送统计概览" [nzBordered]="false">
+  <div class="filter-controls">
+    <nz-range-picker
+      [(ngModel)]="dateRange"
+      (ngModelChange)="refreshData()"
+      style="width: 300px; margin-right: 16px"
+    >
+    </nz-range-picker>
+    <button
+      nz-button
+      nzType="primary"
+      (click)="refreshData()"
+      [nzLoading]="isLoading"
+    >
+      <span nz-icon nzType="sync"></span>刷新数据
+    </button>
+  </div>
+
+  <nz-spin [nzSpinning]="isLoading">
+    <!-- 整体统计卡片 -->
+    <nz-card nzTitle="整体统计" style="margin-top: 16px">
+      <div nz-row [nzGutter]="16" *ngIf="overallStats">
+        <div nz-col [nzSpan]="6">
+          <nz-statistic
+            nzTitle="总发送量"
+            [nzValue]="overallStats.totalRecords || 0"
+          >
+          </nz-statistic>
+        </div>
+        <div nz-col [nzSpan]="6">
+          <nz-statistic
+            nzTitle="送达率"
+            [nzValue]="formatPercentage(overallStats.deliveredRate || 0)"
+          >
+          </nz-statistic>
+        </div>
+        <div nz-col [nzSpan]="6">
+          <nz-statistic
+            nzTitle="打开率"
+            [nzValue]="formatPercentage(overallStats.openedRate || 0)"
+          >
+          </nz-statistic>
+        </div>
+        <div nz-col [nzSpan]="6">
+          <nz-statistic
+            nzTitle="失败率"
+            [nzValue]="
+              formatPercentage(overallStats.tokenInvalidationRate || 0)
+            "
+          >
+          </nz-statistic>
+        </div>
+      </div>
+    </nz-card>
+
+    <!-- 图表区域 -->
+    <div nz-row [nzGutter]="16" style="margin-top: 16px">
+      <div nz-col [nzSpan]="12">
+        <nz-card nzTitle="每日消息趋势">
+          <div style="height: 300px">
+            <canvas
+              baseChart
+              [type]="'line'"
+              [data]="lineChartData"
+              [options]="chartOptions"
+            >
+            </canvas>
+          </div>
+        </nz-card>
+      </div>
+      <div nz-col [nzSpan]="12">
+        <nz-card nzTitle="每日送达量">
+          <div style="height: 300px">
+            <canvas
+              baseChart
+              [type]="'bar'"
+              [data]="barChartData"
+              [options]="chartOptions"
+            >
+            </canvas>
+          </div>
+        </nz-card>
+      </div>
+    </div>
+
+    <!-- 统计表格标签页 -->
+    <nz-tabset [(nzSelectedIndex)]="activeTab" style="margin-top: 16px">
+      <nz-tab nzTitle="按策略统计">
+        <nz-table
+          #strategyTable
+          [nzData]="strategyStats"
+          [nzFrontPagination]="false"
+          [nzShowPagination]="false"
+          [nzBordered]="true"
+          [nzSize]="'small'"
+        >
+          <thead>
+            <tr>
+              <th>策略名称</th>
+              <th>总发送量</th>
+              <th>送达率</th>
+              <th>打开率</th>
+              <th>失败率</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr *ngFor="let item of strategyTable.data">
+              <td>{{ item.strategyName || "-" }}</td>
+              <td>{{ item.totalSent || 0 }}</td>
+              <td>
+                <nz-progress
+                  [nzPercent]="(preciseRound(item.deliveredRate, 2) || 0) * 100"
+                  nzSize="small"
+                  nzStatus="normal"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.deliveredRate || 0) }}
+              </td>
+              <td>
+                <nz-progress
+                  [nzPercent]="(preciseRound(item.openedRate, 2) || 0) * 100"
+                  nzSize="small"
+                  nzStatus="active"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.openedRate || 0) }}
+              </td>
+              <td>
+                <nz-progress
+                  [nzPercent]="
+                    (preciseRound(item.tokenInvalidationRate, 2) || 0) * 100
+                  "
+                  nzSize="small"
+                  nzStatus="exception"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.tokenInvalidationRate || 0) }}
+              </td>
+              <td>
+                <a
+                  (click)="navigateToStrategy(item.strategyName)"
+                  *ngIf="item.strategyId"
+                  >查看详情</a
+                >
+              </td>
+            </tr>
+          </tbody>
+        </nz-table>
+        <nz-empty *ngIf="strategyStats.length === 0 && !isLoading"></nz-empty>
+      </nz-tab>
+
+      <nz-tab nzTitle="按模板统计">
+        <nz-table
+          #templateTable
+          [nzData]="templateStats"
+          [nzFrontPagination]="false"
+          [nzShowPagination]="false"
+          [nzBordered]="true"
+          [nzSize]="'small'"
+        >
+          <thead>
+            <tr>
+              <th>模板名称</th>
+              <th>总发送量</th>
+              <th>送达率</th>
+              <th>打开率</th>
+              <th>失败率</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr *ngFor="let item of templateTable.data">
+              <td>{{ item.templateName || "-" }}</td>
+              <td>{{ item.totalSent || 0 }}</td>
+              <td>
+                <nz-progress
+                  [nzPercent]="(preciseRound(item.deliveredRate, 2) || 0) * 100"
+                  nzSize="small"
+                  nzStatus="normal"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.deliveredRate || 0) }}
+              </td>
+              <td>
+                <nz-progress
+                  [nzPercent]="(preciseRound(item.openedRate, 2) || 0) * 100"
+                  nzSize="small"
+                  nzStatus="active"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.openedRate || 0) }}
+              </td>
+              <td>
+                <nz-progress
+                  [nzPercent]="
+                    (preciseRound(item.tokenInvalidationRate, 2) || 0) * 100
+                  "
+                  nzSize="small"
+                  nzStatus="exception"
+                >
+                </nz-progress>
+                {{ formatPercentage(item.tokenInvalidationRate || 0) }}
+              </td>
+              <td>
+                <a
+                  (click)="navigateToTemplate(item.templateName)"
+                  *ngIf="item.templateId"
+                  >查看详情</a
+                >
+              </td>
+            </tr>
+          </tbody>
+        </nz-table>
+        <nz-empty *ngIf="templateStats.length === 0 && !isLoading"></nz-empty>
+      </nz-tab>
+    </nz-tabset>
+  </nz-spin>
+</nz-card>

+ 298 - 0
omsapp/src/app/pages/message-dashboard.component.ts

@@ -0,0 +1,298 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule, DatePipe } from '@angular/common';
+import { HttpClient } from '@angular/common/http';
+import { Router } from '@angular/router';
+
+// NG-ZORRO 组件
+import { NzCardModule } from 'ng-zorro-antd/card';
+import { NzGridModule } from 'ng-zorro-antd/grid';
+import { NzStatisticModule } from 'ng-zorro-antd/statistic';
+import { NzTableModule } from 'ng-zorro-antd/table';
+import { NzTabsModule } from 'ng-zorro-antd/tabs';
+import { NzTagModule } from 'ng-zorro-antd/tag';
+import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { NzDividerModule } from 'ng-zorro-antd/divider';
+import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
+import { FormsModule } from '@angular/forms';
+import { NzSelectModule } from 'ng-zorro-antd/select';
+import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzIconModule } from 'ng-zorro-antd/icon';
+import { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
+import { NzProgressModule } from 'ng-zorro-antd/progress';
+import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
+
+// Chart.js
+import { BaseChartDirective } from 'ng2-charts';
+import {
+  Chart,
+  ChartConfiguration,
+  ChartOptions,
+  registerables,
+} from 'chart.js';
+
+@Component({
+  selector: 'app-message-dashboard',
+  standalone: true,
+  imports: [
+    CommonModule,
+    NzCardModule,
+    NzGridModule,
+    NzStatisticModule,
+    NzTableModule,
+    NzTabsModule,
+    NzTagModule,
+    NzSpinModule,
+    NzDividerModule,
+    NzDatePickerModule,
+    FormsModule,
+    NzSelectModule,
+    NzButtonModule,
+    NzIconModule,
+    NzEmptyModule,
+    NzToolTipModule,
+    NzProgressModule,
+    NzDescriptionsModule,
+    DatePipe,
+    BaseChartDirective,
+  ],
+  templateUrl: './message-dashboard.component.html',
+  styleUrls: ['./message-dashboard.component.css'],
+})
+export class MessageDashboardComponent implements OnInit {
+  isLoading = false;
+  overallStats: any = null;
+  strategyStats: any[] = [];
+  templateStats: any[] = []; // 新增模板统计
+  dailyTrends: any[] = [];
+  avgDeliveryTime: any = null;
+  activeTab: number = 0; // 当前激活的标签页
+
+  // 日期范围
+  dateRange: Date[] = [];
+
+  // 图表配置
+  public lineChartData: ChartConfiguration<'line'>['data'] = {
+    labels: [],
+    datasets: [
+      {
+        data: [],
+        label: '消息趋势',
+        borderColor: '#1890ff',
+        backgroundColor: 'rgba(24, 144, 255, 0.2)',
+        tension: 0.4,
+        fill: true,
+      },
+    ],
+  };
+
+  public barChartData: ChartConfiguration<'bar'>['data'] = {
+    labels: [],
+    datasets: [
+      {
+        data: [],
+        label: '消息量',
+        backgroundColor: '#52c41a',
+      },
+    ],
+  };
+
+  public chartOptions: ChartOptions<'line' | 'bar'> = {
+    responsive: true,
+    maintainAspectRatio: false,
+    scales: {
+      y: {
+        beginAtZero: true,
+      },
+    },
+  };
+
+  constructor(
+    private http: HttpClient,
+    private message: NzMessageService,
+    private router: Router
+  ) {
+    Chart.register(...registerables);
+  }
+
+  ngOnInit(): void {
+    this.initCharts();
+    this.loadAllStatistics();
+  }
+
+  private initCharts(): void {
+    this.lineChartData = {
+      labels: [],
+      datasets: [
+        {
+          data: [],
+          label: '消息趋势',
+          borderColor: '#1890ff',
+          backgroundColor: 'rgba(24, 144, 255, 0.2)',
+          tension: 0.4,
+          fill: true,
+        },
+      ],
+    };
+
+    this.barChartData = {
+      labels: [],
+      datasets: [
+        {
+          data: [],
+          label: '消息量',
+          backgroundColor: '#52c41a',
+        },
+      ],
+    };
+  }
+
+  loadAllStatistics(): void {
+    this.isLoading = true;
+
+    Promise.all([
+      this.loadOverallStatistics(),
+      this.loadStrategyStatistics(),
+      this.loadTemplateStatistics(), // 新增模板统计加载
+      this.loadDailyTrends(),
+      this.loadAvgDeliveryTime(),
+    ]).finally(() => {
+      this.isLoading = false;
+      if (this.dailyTrends.length === 0) {
+        this.initCharts();
+      }
+    });
+  }
+
+  loadOverallStatistics(): Promise<void> {
+    return this.http
+      .get('/api/message-records/statistics/overall')
+      .toPromise()
+      .then((data: any) => {
+        this.overallStats = data?.data || null;
+      })
+      .catch((err) => {
+        console.error('Failed to load overall statistics:', err);
+        this.message.error('加载整体统计失败');
+      });
+  }
+
+  loadStrategyStatistics(): Promise<void> {
+    return this.http
+      .get('/api/message-records/statistics/by-strategy')
+      .toPromise()
+      .then((data: any) => {
+        this.strategyStats = data?.data || [];
+      })
+      .catch((err) => {
+        console.error('Failed to load strategy statistics:', err);
+        this.message.error('加载策略统计失败');
+      });
+  }
+
+  loadTemplateStatistics(): Promise<void> {
+    return this.http
+      .get('/api/message-records/statistics/by-template')
+      .toPromise()
+      .then((data: any) => {
+        this.templateStats = data?.data || [];
+      })
+      .catch((err) => {
+        console.error('Failed to load template statistics:', err);
+        this.message.error('加载模板统计失败');
+      });
+  }
+
+  loadDailyTrends(): Promise<void> {
+    return this.http
+      .get('/api/message-records/statistics/daily-trends')
+      .toPromise()
+      .then((data: any) => {
+        this.dailyTrends = data?.data || [];
+
+        // 更新图表数据
+        this.lineChartData = {
+          labels: this.dailyTrends.map((t) => t.date),
+          datasets: [
+            {
+              ...this.lineChartData.datasets[0],
+              data: this.dailyTrends.map((t) => t.totalSent),
+            },
+          ],
+        };
+
+        this.barChartData = {
+          labels: this.dailyTrends.map((t) => t.date),
+          datasets: [
+            {
+              ...this.barChartData.datasets[0],
+              data: this.dailyTrends.map((t) => t.delivered),
+            },
+          ],
+        };
+      })
+      .catch((err) => {
+        console.error('Failed to load daily trends:', err);
+        this.message.error('加载每日趋势失败');
+      });
+  }
+
+  loadAvgDeliveryTime(): Promise<void> {
+    return this.http
+      .get('/api/message-records/statistics/avg-delivery-time')
+      .toPromise()
+      .then((data: any) => {
+        this.avgDeliveryTime = data?.data || null;
+      })
+      .catch((err) => {
+        console.error('Failed to load average delivery time:', err);
+        this.message.error('加载平均送达时间失败');
+      });
+  }
+
+  refreshData(): void {
+    this.loadAllStatistics();
+  }
+
+  navigateToStrategy(strategyName: string): void {
+    this.router.navigate(['/message-strategy'], {
+      queryParams: { templateName: strategyName },
+    });
+  }
+
+  navigateToTemplate(templateName: string): void {
+    this.router.navigate(['/message-template'], {
+      queryParams: { templateName: templateName },
+    });
+  }
+
+  formatPercentage(value: number): string {
+    return (value * 100).toFixed(2) + '%';
+  }
+
+  formatSeconds(seconds: number): string {
+    if (seconds < 60) {
+      return seconds.toFixed(2) + '秒';
+    } else {
+      return (seconds / 60).toFixed(2) + '分钟';
+    }
+  }
+
+  /**
+   * 精准四舍五入
+   * @param {number} num - 需要处理的数字
+   * @param {number} decimalPlaces - 保留的小数位数
+   * @returns {number} 四舍五入后的结果
+   */
+  preciseRound(num: number, decimalPlaces: number) {
+    // 处理小数位数为0的情况(直接用 Math.round)
+    if (decimalPlaces === 0) return Math.round(num);
+
+    // 计算放大倍数(10的decimalPlaces次方)
+    const multiplier = Math.pow(10, decimalPlaces);
+
+    // 放大 → 四舍五入 → 缩小(避免浮点数精度问题,用 Number() 确保类型)
+    return Number(Math.round(num * multiplier) / multiplier);
+  }
+}

Some files were not shown because too many files changed in this diff