Преглед изворни кода

将message-sender做成独立服务

guoziyun пре 9 месеци
родитељ
комит
8aaae7bbe0

+ 3 - 1
.prettierrc

@@ -1,3 +1,5 @@
 {
-  "printWidth": 200
+  "printWidth": 200,
+  "singleAttributePerLine": false,
+  "htmlWhitespaceSensitivity": "ignore"
 }

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

@@ -13,7 +13,7 @@ const settings = [
     // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
     ["done-rate", "10 0 * * *", require("./done-rate2")], // 每天凌晨0点10分, 统计作品完成率
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
-    ["message-sender", "*/5 * * * *", require("./message-sender")], // 每5分钟运行一次
+    // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
     // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
     ["fcm-notify", "30 18 * * *", require("./fcm-notify")], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
 ];

+ 1 - 1
oms/dist/services/cron-jobs/message-sender.js

@@ -67,7 +67,7 @@ const run = async () => {
                     if (isTokenInvalid) {
                         // 如果是Token无效,则从用户文档中移除fcmToken
                         console.warn(`[Message Sender] Invalid FCM Token for user ${record.uid}. Clearing token.`);
-                        await userModel_1.User.updateOne({ uid: record.uid }, { $unset: { fmToken: 1 } });
+                        await userModel_1.User.findOneAndUpdate({ uid: record.uid }, { fmToken: null });
                         updateOps.errno = `Invalid FCM Token. Token has been cleared. Original error: ${result.message}`;
                     }
                     await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, updateOps);

+ 144 - 0
oms/dist/services/message-sender-service.js

@@ -0,0 +1,144 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = void 0;
+const mongoose_1 = __importDefault(require("mongoose"));
+const messageRecordModel_1 = require("../src/models/messageRecordModel");
+const userModel_1 = require("../src/models/userModel");
+const fcmService_1 = require("../src/services/fcmService");
+const utils_1 = require("../src/libs/utils");
+const database_1 = require("../src/database"); // 从封装的模块导入连接函数
+const fcmService = fcmService_1.FCMService.getInstance();
+const DELAY_MINUTES = 10;
+/**
+ * 等待指定分钟数。
+ * @param minutes 等待分钟数
+ * @returns Promise
+ */
+const delay = (minutes) => {
+    return new Promise((resolve) => setTimeout(resolve, minutes * 60 * 1000));
+};
+/**
+ * 核心消息发送逻辑:
+ * 1. 查询所有状态为0(未发送)且计划发送时间已到的消息记录。
+ * 2. 遍历这些消息,为每个消息找到对应的用户和设备令牌。
+ * 3. 顺序调用 FCMService 发送消息。
+ * 4. 根据发送结果更新 messageRecord 的状态和相关字段。
+ *
+ * @returns {Promise<void>}
+ */
+const processMessages = async () => {
+    try {
+        // 检查连接状态,确保在执行查询前数据库已连接
+        if (mongoose_1.default.connection.readyState !== 1) {
+            console.error("[Message Sender] Database connection is not ready. Skipping cycle.");
+            return;
+        }
+        const recordsToSend = await messageRecordModel_1.MessageRecord.find({
+            status: 0,
+            plannedSendAt: { $lte: new Date() },
+        });
+        if (recordsToSend.length === 0) {
+            console.log(`[Message Sender] No messages found to send. Waiting ${DELAY_MINUTES} minutes.`);
+            return;
+        }
+        console.log(`[Message Sender] Found ${recordsToSend.length} messages to send. Starting sequential process.`);
+        // 使用 for...of 循环确保消息按顺序逐个发送,保证稳定性
+        for (const record of recordsToSend) {
+            try {
+                const user = await userModel_1.User.findOne({ uid: record.uid });
+                if (!user || !user.fmToken) {
+                    console.warn(`[Message Sender] User ${record.uid} not found or no FCM token, updating record status to failed.`);
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                        status: -1,
+                        actualSendAt: new Date(),
+                        errno: "User not found or no FCM token",
+                    });
+                    continue; // 继续处理下一个记录
+                }
+                const messageData = (0, utils_1.filterEmptyProps)({
+                    msgid: record._id.toString(),
+                    title: record.title,
+                    content: record.content,
+                    image: record.image || "",
+                    bigger: record.bigger?.toString() || "false",
+                    action: record.action || "",
+                    param: record.param || "",
+                    extend: record.extend || "",
+                });
+                const result = await fcmService.sendMessage(user.fmToken, messageData);
+                if (result instanceof Error) {
+                    const isTokenInvalid = result.message.includes("messaging/invalid-argument") || result.message.includes("messaging/registration-token-not-registered") || result.message.includes("messaging/unregistered");
+                    let updateOps = {
+                        status: -1,
+                        actualSendAt: new Date(),
+                        errno: result.message,
+                    };
+                    if (isTokenInvalid) {
+                        console.warn(`[Message Sender] Invalid FCM Token for user ${record.uid}. Clearing token.`);
+                        await userModel_1.User.findOneAndUpdate({ uid: record.uid }, { fmToken: null });
+                        updateOps.errno = `Invalid FCM Token. Token has been cleared. Original error: ${result.message}`;
+                    }
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, updateOps);
+                }
+                else {
+                    await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                        status: 1,
+                        actualSendAt: new Date(),
+                        fcmReceipt: result,
+                        errno: null,
+                    });
+                }
+            }
+            catch (error) {
+                console.error(`[Message Sender] Failed to process message record ${record._id}:`, error);
+                await messageRecordModel_1.MessageRecord.findByIdAndUpdate(record._id, {
+                    status: -1,
+                    actualSendAt: new Date(),
+                    errno: "Internal server error while processing message",
+                });
+            }
+        }
+        console.log(`[Message Sender] Finished processing ${recordsToSend.length} messages.`);
+    }
+    catch (err) {
+        console.error("[Message Sender] Error in message sender service:", err);
+    }
+};
+/**
+ * 启动消息发送服务。
+ * @returns Promise<void>
+ */
+const run = async () => {
+    console.log("[Message Sender] Service starting...");
+    await (0, database_1.connectToDatabase)();
+    while (true) {
+        console.log(`[Message Sender] Running a new cycle...`);
+        const startTime = new Date();
+        await processMessages();
+        const duration = new Date().getTime() - startTime.getTime();
+        console.log(`[Message Sender] Cycle finished in ${duration / 1000} seconds. Delaying for ${DELAY_MINUTES} minutes.`);
+        await delay(DELAY_MINUTES);
+    }
+};
+exports.run = run;
+// 如果此文件被直接运行
+if (require.main === module) {
+    // Handle graceful shutdown
+    process.on("SIGINT", async () => {
+        console.log("[Message Sender] Received SIGINT. Shutting down gracefully...");
+        await (0, database_1.disconnectFromDatabase)();
+        process.exit(0);
+    });
+    process.on("SIGTERM", async () => {
+        console.log("[Message Sender] Received SIGTERM. Shutting down gracefully...");
+        await (0, database_1.disconnectFromDatabase)();
+        process.exit(0);
+    });
+    (0, exports.run)().catch((err) => {
+        console.error("Failed to start message sender service:", err);
+        process.exit(1);
+    });
+}

+ 34 - 0
oms/dist/src/services/fcmService.js

@@ -88,5 +88,39 @@ class FCMService {
             return error;
         }
     }
+    /**
+     * 批量发送 FCM 消息给多个设备令牌。
+     * @param tokens 接收消息的设备令牌数组
+     * @param data 消息数据,用于客户端解析
+     * @returns 批量发送结果,包含每个令牌的成功/失败信息
+     */
+    async sendBatch(tokens, data) {
+        const message = {
+            data: data,
+            tokens: tokens,
+        };
+        try {
+            const response = await admin.messaging().sendEachForMulticast(message);
+            console.log(`Successfully sent batch message: ${response.successCount} succeeded, ${response.failureCount} failed.`);
+            return response;
+        }
+        catch (error) {
+            console.error("Error sending message batch:", error);
+            // 构建一个符合 FirebaseError 类型的对象来满足类型要求
+            const firebaseError = {
+                code: "messaging/unknown-error",
+                message: `Failed to send batch: ${error.message}`,
+                toJSON: () => ({ code: "messaging/unknown-error", message: `Failed to send batch: ${error.message}` }),
+            };
+            return {
+                successCount: 0,
+                failureCount: tokens.length,
+                responses: tokens.map(() => ({
+                    success: false,
+                    error: firebaseError,
+                })),
+            };
+        }
+    }
 }
 exports.FCMService = FCMService;

+ 8 - 0
oms/ecosystem.config.js

@@ -153,5 +153,13 @@ module.exports = {
       autorestart: true,
       watch: false,
     },
+    {
+      name: "schedule-message-sender", // 专门负责schedule消息发送
+      script: "dist/services/message-sender-service.js",
+      instances: 1,
+      exec_mode: "fork",
+      autorestart: true,
+      watch: false,
+    },
   ],
 };

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

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
oms/public/app/main-Z7J4MUAA.js


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

@@ -15,7 +15,7 @@ const settings: [string, string, CronJobModule][] = [
   // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
   ["done-rate", "10 0 * * *", require("./done-rate2") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
   ["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分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
   // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
   ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试
 ];

+ 1 - 1
oms/services/cron-jobs/message-sender.ts

@@ -73,7 +73,7 @@ export const run = async () => {
           if (isTokenInvalid) {
             // 如果是Token无效,则从用户文档中移除fcmToken
             console.warn(`[Message Sender] Invalid FCM Token for user ${record.uid}. Clearing token.`);
-            await User.updateOne({ uid: record.uid }, { $unset: { fmToken: 1 } });
+            await User.findOneAndUpdate({ uid: record.uid }, { fmToken: null });
             updateOps.errno = `Invalid FCM Token. Token has been cleared. Original error: ${result.message}`;
           }
 

+ 156 - 0
oms/services/message-sender-service.ts

@@ -0,0 +1,156 @@
+import mongoose from "mongoose";
+import { IMessageRecord, MessageRecord } from "../src/models/messageRecordModel";
+import { User } from "../src/models/userModel";
+import { FCMService } from "../src/services/fcmService";
+import { filterEmptyProps } from "../src/libs/utils";
+import { connectToDatabase, disconnectFromDatabase } from "../src/database"; // 从封装的模块导入连接函数
+
+const fcmService = FCMService.getInstance();
+const DELAY_MINUTES = 10;
+
+/**
+ * 等待指定分钟数。
+ * @param minutes 等待分钟数
+ * @returns Promise
+ */
+const delay = (minutes: number): Promise<void> => {
+  return new Promise((resolve) => setTimeout(resolve, minutes * 60 * 1000));
+};
+
+/**
+ * 核心消息发送逻辑:
+ * 1. 查询所有状态为0(未发送)且计划发送时间已到的消息记录。
+ * 2. 遍历这些消息,为每个消息找到对应的用户和设备令牌。
+ * 3. 顺序调用 FCMService 发送消息。
+ * 4. 根据发送结果更新 messageRecord 的状态和相关字段。
+ *
+ * @returns {Promise<void>}
+ */
+const processMessages = async () => {
+  try {
+    // 检查连接状态,确保在执行查询前数据库已连接
+    if (mongoose.connection.readyState !== 1) {
+      console.error("[Message Sender] Database connection is not ready. Skipping cycle.");
+      return;
+    }
+
+    const recordsToSend: IMessageRecord[] = await MessageRecord.find({
+      status: 0,
+      plannedSendAt: { $lte: new Date() },
+    });
+
+    if (recordsToSend.length === 0) {
+      console.log(`[Message Sender] No messages found to send. Waiting ${DELAY_MINUTES} minutes.`);
+      return;
+    }
+
+    console.log(`[Message Sender] Found ${recordsToSend.length} messages to send. Starting sequential process.`);
+
+    // 使用 for...of 循环确保消息按顺序逐个发送,保证稳定性
+    for (const record of recordsToSend) {
+      try {
+        const user = await User.findOne({ uid: record.uid });
+        if (!user || !user.fmToken) {
+          console.warn(`[Message Sender] User ${record.uid} not found or no FCM token, updating record status to failed.`);
+          await MessageRecord.findByIdAndUpdate(record._id, {
+            status: -1,
+            actualSendAt: new Date(),
+            errno: "User not found or no FCM token",
+          });
+          continue; // 继续处理下一个记录
+        }
+
+        const messageData = filterEmptyProps({
+          msgid: record._id.toString(),
+          title: record.title,
+          content: record.content,
+          image: record.image || "",
+          bigger: record.bigger?.toString() || "false",
+          action: record.action || "",
+          param: record.param || "",
+          extend: record.extend || "",
+        });
+
+        const result = await fcmService.sendMessage(user.fmToken, messageData);
+
+        if (result instanceof Error) {
+          const isTokenInvalid =
+            result.message.includes("messaging/invalid-argument") || result.message.includes("messaging/registration-token-not-registered") || result.message.includes("messaging/unregistered");
+
+          let updateOps: any = {
+            status: -1,
+            actualSendAt: new Date(),
+            errno: result.message,
+          };
+
+          if (isTokenInvalid) {
+            console.warn(`[Message Sender] Invalid FCM Token for user ${record.uid}. Clearing token.`);
+            await User.findOneAndUpdate({ uid: record.uid }, { fmToken: null });
+            updateOps.errno = `Invalid FCM Token. Token has been cleared. Original error: ${result.message}`;
+          }
+
+          await MessageRecord.findByIdAndUpdate(record._id, updateOps);
+        } else {
+          await MessageRecord.findByIdAndUpdate(record._id, {
+            status: 1,
+            actualSendAt: new Date(),
+            fcmReceipt: result,
+            errno: null,
+          });
+        }
+      } catch (error) {
+        console.error(`[Message Sender] Failed to process message record ${record._id}:`, error);
+        await MessageRecord.findByIdAndUpdate(record._id, {
+          status: -1,
+          actualSendAt: new Date(),
+          errno: "Internal server error while processing message",
+        });
+      }
+    }
+
+    console.log(`[Message Sender] Finished processing ${recordsToSend.length} messages.`);
+  } catch (err) {
+    console.error("[Message Sender] Error in message sender service:", err);
+  }
+};
+
+/**
+ * 启动消息发送服务。
+ * @returns Promise<void>
+ */
+export const run = async () => {
+  console.log("[Message Sender] Service starting...");
+  await connectToDatabase();
+
+  while (true) {
+    console.log(`[Message Sender] Running a new cycle...`);
+    const startTime = new Date();
+
+    await processMessages();
+
+    const duration = new Date().getTime() - startTime.getTime();
+    console.log(`[Message Sender] Cycle finished in ${duration / 1000} seconds. Delaying for ${DELAY_MINUTES} minutes.`);
+
+    await delay(DELAY_MINUTES);
+  }
+};
+
+// 如果此文件被直接运行
+if (require.main === module) {
+  // Handle graceful shutdown
+  process.on("SIGINT", async () => {
+    console.log("[Message Sender] Received SIGINT. Shutting down gracefully...");
+    await disconnectFromDatabase();
+    process.exit(0);
+  });
+  process.on("SIGTERM", async () => {
+    console.log("[Message Sender] Received SIGTERM. Shutting down gracefully...");
+    await disconnectFromDatabase();
+    process.exit(0);
+  });
+
+  run().catch((err) => {
+    console.error("Failed to start message sender service:", err);
+    process.exit(1);
+  });
+}

+ 38 - 0
oms/src/services/fcmService.ts

@@ -2,6 +2,7 @@
 
 import * as admin from "firebase-admin";
 import path from "path";
+import { BatchResponse, MulticastMessage, MessagingTopicManagementResponse, SendResponse } from "firebase-admin/messaging";
 
 // 从配置文件加载服务账号
 // 服务账号文件路径在 oms/config/fcm-service-account.json
@@ -56,4 +57,41 @@ export class FCMService {
       return error as Error;
     }
   }
+
+  /**
+   * 批量发送 FCM 消息给多个设备令牌。
+   * @param tokens 接收消息的设备令牌数组
+   * @param data 消息数据,用于客户端解析
+   * @returns 批量发送结果,包含每个令牌的成功/失败信息
+   */
+  public async sendBatch(tokens: string[], data: any): Promise<BatchResponse> {
+    const message: MulticastMessage = {
+      data: data,
+      tokens: tokens,
+    };
+
+    try {
+      const response = await admin.messaging().sendEachForMulticast(message);
+      console.log(`Successfully sent batch message: ${response.successCount} succeeded, ${response.failureCount} failed.`);
+      return response;
+    } catch (error) {
+      console.error("Error sending message batch:", error);
+
+      // 构建一个符合 FirebaseError 类型的对象来满足类型要求
+      const firebaseError = {
+        code: "messaging/unknown-error",
+        message: `Failed to send batch: ${(error as Error).message}`,
+        toJSON: () => ({ code: "messaging/unknown-error", message: `Failed to send batch: ${(error as Error).message}` }),
+      } as admin.FirebaseError;
+
+      return {
+        successCount: 0,
+        failureCount: tokens.length,
+        responses: tokens.map(() => ({
+          success: false,
+          error: firebaseError,
+        })),
+      };
+    }
+  }
 }

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

@@ -223,3 +223,55 @@ nz-spin {
 nz-card[nzTitle] {
   border-top: 3px solid #1890ff;
 }
+
+/* 新增图表图例样式 */
+.chart-legend {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 16px;
+  gap: 16px;
+  justify-content: center;
+}
+
+.chart-legend div {
+  display: flex;
+  align-items: center;
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.legend-color {
+  display: inline-block;
+  width: 12px;
+  height: 12px;
+  margin-right: 6px;
+  border-radius: 2px;
+}
+
+.legend-line {
+  display: inline-block;
+  width: 20px;
+  height: 0;
+  border-bottom: 2px solid;
+  margin-right: 6px;
+}
+
+/* 调整图表容器 */
+.chart-container {
+  position: relative;
+  width: 100%;
+  min-height: 400px;
+  margin-top: 8px;
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+  .chart-legend {
+    gap: 8px;
+    justify-content: flex-start;
+  }
+
+  .chart-legend div {
+    font-size: 11px;
+  }
+}

+ 56 - 15
omsapp/src/app/pages/message-dashboard.component.html

@@ -80,7 +80,7 @@
     <!-- 图表区域 -->
     <div nz-row [nzGutter]="16" style="margin-top: 16px">
       <div nz-col [nzSpan]="24">
-        <nz-card nzTitle="每日消息量统计">
+        <nz-card nzTitle="每日消息量与转化率">
           <div class="chart-container">
             <canvas
               baseChart
@@ -89,20 +89,61 @@
               [options]="combinedChartOptions"
             ></canvas>
           </div>
-        </nz-card>
-      </div>
-    </div>
-
-    <div nz-row [nzGutter]="16" style="margin-top: 16px">
-      <div nz-col [nzSpan]="24">
-        <nz-card nzTitle="每日成功率趋势">
-          <div class="chart-container">
-            <canvas
-              baseChart
-              [type]="'line'"
-              [data]="rateChartData"
-              [options]="combinedChartOptions"
-            ></canvas>
+          <div class="chart-legend">
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #1890ff"
+              ></span>
+              总发送量
+            </div>
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #52c41a"
+              ></span>
+              成功发送
+            </div>
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #13c2c2"
+              ></span>
+              已送达
+            </div>
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #722ed1"
+              ></span>
+              已打开
+            </div>
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #faad14"
+              ></span>
+              展示数
+            </div>
+            <div>
+              <span
+                class="legend-color"
+                style="background-color: #f5222d"
+              ></span>
+              失败数
+            </div>
+            <div>
+              <span class="legend-line" style="border-color: #13c2c2"></span>
+              送达率
+            </div>
+            <div>
+              <span class="legend-line" style="border-color: #faad14"></span>
+              展示率
+            </div>
+            <div>
+              <span class="legend-line" style="border-color: #722ed1"></span>
+              点击率
+            </div>
           </div>
         </nz-card>
       </div>

+ 55 - 40
omsapp/src/app/pages/message-dashboard.component.ts

@@ -76,7 +76,7 @@ export class MessageDashboardComponent implements OnInit {
   dateRange: Date[] = [];
 
   // 组合图表配置
-  public combinedChartData: ChartConfiguration<'bar'>['data'] = {
+  public combinedChartData: ChartConfiguration<'bar' | 'line'>['data'] = {
     labels: [],
     datasets: [
       {
@@ -115,47 +115,42 @@ export class MessageDashboardComponent implements OnInit {
         backgroundColor: '#f5222d',
         yAxisID: 'y',
       },
-    ],
-  };
-
-  public rateChartData: ChartConfiguration<'line'>['data'] = {
-    labels: [],
-    datasets: [
-      {
-        label: '发送成功率',
-        data: [],
-        borderColor: '#1890ff',
-        backgroundColor: 'rgba(24, 144, 255, 0.1)',
-        yAxisID: 'y1',
-        tension: 0.3,
-        fill: true,
-      },
+      // 折线图数据集
       {
         label: '送达率',
         data: [],
-        borderColor: '#52c41a',
-        backgroundColor: 'rgba(82, 196, 26, 0.1)',
+        borderColor: '#13c2c2',
+        backgroundColor: 'transparent',
         yAxisID: 'y1',
+        type: 'line',
         tension: 0.3,
-        fill: true,
+        borderWidth: 2,
+        pointRadius: 4,
+        pointHoverRadius: 6,
       },
       {
         label: '展示率',
         data: [],
         borderColor: '#faad14',
-        backgroundColor: 'rgba(250, 173, 20, 0.1)',
+        backgroundColor: 'transparent',
         yAxisID: 'y1',
+        type: 'line',
         tension: 0.3,
-        fill: true,
+        borderWidth: 2,
+        pointRadius: 4,
+        pointHoverRadius: 6,
       },
       {
         label: '点击率',
         data: [],
         borderColor: '#722ed1',
-        backgroundColor: 'rgba(114, 46, 209, 0.1)',
+        backgroundColor: 'transparent',
         yAxisID: 'y1',
+        type: 'line',
         tension: 0.3,
-        fill: true,
+        borderWidth: 2,
+        pointRadius: 4,
+        pointHoverRadius: 6,
       },
     ],
   };
@@ -167,6 +162,24 @@ export class MessageDashboardComponent implements OnInit {
       tooltip: {
         mode: 'index',
         intersect: false,
+        callbacks: {
+          label: (context) => {
+            let label = context.dataset.label || '';
+            if (label) {
+              label += ': ';
+            }
+            // 如果是折线图(转化率),格式化显示两位小数并添加百分号
+            if (context.datasetIndex >= 6) {
+              // 假设6-8是折线图数据集
+              const value = typeof context.raw === 'number' ? context.raw : 0;
+              label += value.toFixed(2) + '%';
+            } else {
+              // 柱状图数据保持不变
+              label += context.raw;
+            }
+            return label;
+          },
+        },
       },
       legend: {
         position: 'top',
@@ -196,6 +209,12 @@ export class MessageDashboardComponent implements OnInit {
         grid: {
           drawOnChartArea: false,
         },
+        ticks: {
+          callback: (value) => {
+            // 确保刻度值显示两位小数
+            return typeof value === 'number' ? value.toFixed(2) + '%' : value;
+          },
+        },
       },
     },
   };
@@ -318,28 +337,24 @@ export class MessageDashboardComponent implements OnInit {
           ...this.combinedChartData.datasets[5],
           data: this.dailyTrends.map((t) => t.failed || 0),
         },
-      ],
-    };
-
-    // 更新比率图表数据
-    this.rateChartData = {
-      labels: this.dailyTrends.map((t) => this.formatDate(t.date)),
-      datasets: [
-        {
-          ...this.rateChartData.datasets[0],
-          data: this.dailyTrends.map((t) => (t.sentSuccessRate || 0) * 100),
-        },
+        // 折线图数据
         {
-          ...this.rateChartData.datasets[1],
-          data: this.dailyTrends.map((t) => (t.deliveredRate || 0) * 100),
+          ...this.combinedChartData.datasets[6],
+          data: this.dailyTrends.map((t) =>
+            this.preciseRound((t.deliveredRate || 0) * 100, 2)
+          ),
         },
         {
-          ...this.rateChartData.datasets[2],
-          data: this.dailyTrends.map((t) => (t.displayRate || 0) * 100),
+          ...this.combinedChartData.datasets[7],
+          data: this.dailyTrends.map((t) =>
+            this.preciseRound((t.displayRate || 0) * 100, 2)
+          ),
         },
         {
-          ...this.rateChartData.datasets[3],
-          data: this.dailyTrends.map((t) => (t.clickThroughRate || 0) * 100),
+          ...this.combinedChartData.datasets[8],
+          data: this.dailyTrends.map((t) =>
+            this.preciseRound((t.clickThroughRate || 0) * 100, 2)
+          ),
         },
       ],
     };

Неке датотеке нису приказане због велике количине промена