guoziyun 1 månad sedan
förälder
incheckning
962f916437

+ 1 - 0
oms/OPTIMIZATION_TRACKER.md

@@ -137,6 +137,7 @@
   - 新增汇总接口 `api/message/statistics/summary`,首屏合并返回 overall + daily-trends + by-strategy(默认分页)。
   - 前端首屏加载改为优先请求 `summary`,减少首屏多接口并发重查询。
   - `api/message-records` 列表查询在无筛选场景改为 `estimatedDocumentCount`(替代全量 `countDocuments`),并增加排序白名单与 `lean()` 返回,缓解千万级数据下列表超时。
+  - 修复 `summary` 慢查询:将统计去重从 `$addToSet(uid)` 改为分层聚合去重计数(避免大数组内存压力),并为 `summary` 增加默认近7天时间窗与前端同参请求去重。
 - 下一步:
   - 增加缓存失效策略与命中率监控(Phase 1)。
   - 补充 summary 与各维度接口的基准压测数据(Phase 1)。

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

@@ -10,6 +10,23 @@ class MessageRecordController {
         const end = endDate ? new Date(endDate) : undefined;
         return { start, end };
     }
+    withDefaultDateRange(start, end, defaultDays = MessageRecordController.DEFAULT_SUMMARY_RANGE_DAYS) {
+        const now = new Date();
+        if (!start && !end) {
+            const defaultEnd = now;
+            const defaultStart = new Date(defaultEnd.getTime() - (defaultDays - 1) * 24 * 60 * 60 * 1000);
+            return { start: defaultStart, end: defaultEnd };
+        }
+        if (start && !end) {
+            const computedEnd = new Date(start.getTime() + (defaultDays - 1) * 24 * 60 * 60 * 1000);
+            return { start, end: computedEnd };
+        }
+        if (!start && end) {
+            const computedStart = new Date(end.getTime() - (defaultDays - 1) * 24 * 60 * 60 * 1000);
+            return { start: computedStart, end };
+        }
+        return { start, end };
+    }
     validateDateRange(res, start, end, maxDays = MessageRecordController.MAX_RANGE_DAYS_GENERAL) {
         if (!start || !end) {
             return true;
@@ -214,7 +231,8 @@ class MessageRecordController {
         this.getStatisticsSummary = async (req, res) => {
             try {
                 const done = this.logStatsRequest("summary", req);
-                const { start, end } = this.parseDateRange(req);
+                const parsed = this.parseDateRange(req);
+                const { start, end } = this.withDefaultDateRange(parsed.start, parsed.end);
                 if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
                     return res;
                 }
@@ -548,4 +566,5 @@ class MessageRecordController {
 MessageRecordController.MAX_RANGE_DAYS_GENERAL = 180;
 MessageRecordController.MAX_RANGE_DAYS_DIMENSION = 90;
 MessageRecordController.MAX_RANGE_DAYS_MULTI_DIMENSION = 31;
+MessageRecordController.DEFAULT_SUMMARY_RANGE_DAYS = 7;
 exports.default = new MessageRecordController();

+ 32 - 11
oms/dist/src/services/messageRecordService.js

@@ -343,7 +343,7 @@ class MessageRecordService {
                 $sum: {
                     $cond: [
                         { $and: [{ $gte: ["$_id.status", 2] }, { $eq: ["$_id.inforeground", false] }] },
-                        { $size: "$uniqueUids" }, // 计算去重后的用户
+                        "$uniqueUsers", // 由上游分层聚合提前去重计
                         0,
                     ],
                 },
@@ -351,7 +351,7 @@ class MessageRecordService {
             openedUsers: {
                 // 点击人数:至少点击1次的独立用户数
                 $sum: {
-                    $cond: [{ $eq: ["$_id.status", 3] }, { $size: "$uniqueUids" }, 0],
+                    $cond: [{ $eq: ["$_id.status", 3] }, "$uniqueUsers", 0],
                 },
             },
         };
@@ -421,22 +421,36 @@ class MessageRecordService {
             if (Object.keys(matchConditions).length > 0) {
                 pipeline.push({ $match: matchConditions });
             }
-            // 构建分组键
+            // 构建细粒度分组键(包含uid,先做去重基底)
             const groupId = {};
             groupFields.forEach((field) => {
                 groupId[field] = `$${field}`;
             });
             groupId.status = "$status";
             groupId.inforeground = "$inforeground";
-            // 第一阶段分组 - 收集唯一用户ID
+            groupId.uid = "$uid";
+            // 第一阶段分组 - 维度+状态+uid 聚合,避免使用 $addToSet 大数组
             pipeline.push({
                 $group: {
                     _id: groupId,
-                    count: { $sum: 1 }, // 人次统计
-                    uniqueUids: { $addToSet: "$uid" }, // 收集该分组下的唯一用户
+                    msgCount: { $sum: 1 },
                 },
             });
-            // 第二阶段分组 - 按主要维度汇总
+            // 第二阶段分组 - 收敛到维度+状态,保留人次统计和去重人数统计
+            const statusLevelGroupId = {};
+            groupFields.forEach((field) => {
+                statusLevelGroupId[field] = `$_id.${field}`;
+            });
+            statusLevelGroupId.status = "$_id.status";
+            statusLevelGroupId.inforeground = "$_id.inforeground";
+            pipeline.push({
+                $group: {
+                    _id: statusLevelGroupId,
+                    count: { $sum: "$msgCount" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            });
+            // 第三阶段分组 - 按主要维度汇总
             const mainGroupId = {};
             groupFields.forEach((field) => {
                 mainGroupId[field] = `$_id.${field}`;
@@ -497,12 +511,19 @@ class MessageRecordService {
                     uid: "$uid", // 保留uid用于去重统计
                 },
             });
-            // 按日期、状态和前景标志分组 - 收集唯一用户
+            // 先按日期、状态、前景、uid 聚合,避免 $addToSet 占用大量内存
+            pipeline.push({
+                $group: {
+                    _id: { date: "$date", status: "$status", inforeground: "$inforeground", uid: "$uid" },
+                    msgCount: { $sum: 1 },
+                },
+            });
+            // 再收敛到日期+状态+前景,得到人次和去重人数
             pipeline.push({
                 $group: {
-                    _id: { date: "$date", status: "$status", inforeground: "$inforeground" },
-                    count: { $sum: 1 }, // 人次统计
-                    uniqueUids: { $addToSet: "$uid" }, // 收集该分组下的唯一用户
+                    _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+                    count: { $sum: "$msgCount" },
+                    uniqueUsers: { $sum: 1 },
                 },
             });
             // 按日期汇总 - 计算人数统计

+ 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-DG6NCAZH.js" type="module"></script></body>
+  <script src="polyfills-B6TNHZQ6.js" type="module"></script><script src="main-Y4TRW7U5.js" type="module"></script></body>
 </html>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
oms/public/app/main-Y4TRW7U5.js


+ 24 - 1
oms/src/controllers/messageRecordController.ts

@@ -9,6 +9,7 @@ class MessageRecordController {
   private static readonly MAX_RANGE_DAYS_GENERAL = 180;
   private static readonly MAX_RANGE_DAYS_DIMENSION = 90;
   private static readonly MAX_RANGE_DAYS_MULTI_DIMENSION = 31;
+  private static readonly DEFAULT_SUMMARY_RANGE_DAYS = 7;
 
   private parseDateRange(req: Request) {
     const { startDate, endDate } = req.query;
@@ -17,6 +18,27 @@ class MessageRecordController {
     return { start, end };
   }
 
+  private withDefaultDateRange(start?: Date, end?: Date, defaultDays: number = MessageRecordController.DEFAULT_SUMMARY_RANGE_DAYS) {
+    const now = new Date();
+    if (!start && !end) {
+      const defaultEnd = now;
+      const defaultStart = new Date(defaultEnd.getTime() - (defaultDays - 1) * 24 * 60 * 60 * 1000);
+      return { start: defaultStart, end: defaultEnd };
+    }
+
+    if (start && !end) {
+      const computedEnd = new Date(start.getTime() + (defaultDays - 1) * 24 * 60 * 60 * 1000);
+      return { start, end: computedEnd };
+    }
+
+    if (!start && end) {
+      const computedStart = new Date(end.getTime() - (defaultDays - 1) * 24 * 60 * 60 * 1000);
+      return { start: computedStart, end };
+    }
+
+    return { start, end };
+  }
+
   private validateDateRange(res: Response, start?: Date, end?: Date, maxDays: number = MessageRecordController.MAX_RANGE_DAYS_GENERAL): boolean {
     if (!start || !end) {
       return true;
@@ -250,7 +272,8 @@ class MessageRecordController {
   public getStatisticsSummary = async (req: Request, res: Response): Promise<Response> => {
     try {
       const done = this.logStatsRequest("summary", req);
-      const { start, end } = this.parseDateRange(req);
+      const parsed = this.parseDateRange(req);
+      const { start, end } = this.withDefaultDateRange(parsed.start, parsed.end);
       if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
         return res;
       }

+ 35 - 11
oms/src/services/messageRecordService.ts

@@ -445,7 +445,7 @@ export class MessageRecordService {
         $sum: {
           $cond: [
             { $and: [{ $gte: ["$_id.status", 2] }, { $eq: ["$_id.inforeground", false] }] },
-            { $size: "$uniqueUids" }, // 计算去重后的用户
+            "$uniqueUsers", // 由上游分层聚合提前去重计
             0,
           ],
         },
@@ -453,7 +453,7 @@ export class MessageRecordService {
       openedUsers: {
         // 点击人数:至少点击1次的独立用户数
         $sum: {
-          $cond: [{ $eq: ["$_id.status", 3] }, { $size: "$uniqueUids" }, 0],
+          $cond: [{ $eq: ["$_id.status", 3] }, "$uniqueUsers", 0],
         },
       },
     };
@@ -539,24 +539,40 @@ export class MessageRecordService {
         pipeline.push({ $match: matchConditions });
       }
 
-      // 构建分组键
+      // 构建细粒度分组键(包含uid,先做去重基底)
       const groupId: any = {};
       groupFields.forEach((field) => {
         groupId[field] = `$${field}`;
       });
       groupId.status = "$status";
       groupId.inforeground = "$inforeground";
+      groupId.uid = "$uid";
 
-      // 第一阶段分组 - 收集唯一用户ID
+      // 第一阶段分组 - 维度+状态+uid 聚合,避免使用 $addToSet 大数组
       pipeline.push({
         $group: {
           _id: groupId,
-          count: { $sum: 1 }, // 人次统计
-          uniqueUids: { $addToSet: "$uid" }, // 收集该分组下的唯一用户
+          msgCount: { $sum: 1 },
         },
       });
 
-      // 第二阶段分组 - 按主要维度汇总
+      // 第二阶段分组 - 收敛到维度+状态,保留人次统计和去重人数统计
+      const statusLevelGroupId: any = {};
+      groupFields.forEach((field) => {
+        statusLevelGroupId[field] = `$_id.${field}`;
+      });
+      statusLevelGroupId.status = "$_id.status";
+      statusLevelGroupId.inforeground = "$_id.inforeground";
+
+      pipeline.push({
+        $group: {
+          _id: statusLevelGroupId,
+          count: { $sum: "$msgCount" },
+          uniqueUsers: { $sum: 1 },
+        },
+      });
+
+      // 第三阶段分组 - 按主要维度汇总
       const mainGroupId: any = {};
       groupFields.forEach((field) => {
         mainGroupId[field] = `$_id.${field}`;
@@ -630,12 +646,20 @@ export class MessageRecordService {
         },
       });
 
-      // 按日期、状态和前景标志分组 - 收集唯一用户
+      // 先按日期、状态、前景、uid 聚合,避免 $addToSet 占用大量内存
+      pipeline.push({
+        $group: {
+          _id: { date: "$date", status: "$status", inforeground: "$inforeground", uid: "$uid" },
+          msgCount: { $sum: 1 },
+        },
+      });
+
+      // 再收敛到日期+状态+前景,得到人次和去重人数
       pipeline.push({
         $group: {
-          _id: { date: "$date", status: "$status", inforeground: "$inforeground" },
-          count: { $sum: 1 }, // 人次统计
-          uniqueUids: { $addToSet: "$uid" }, // 收集该分组下的唯一用户
+          _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+          count: { $sum: "$msgCount" },
+          uniqueUsers: { $sum: 1 },
         },
       });
 

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

@@ -111,6 +111,8 @@ export class MessageDashboardComponent implements OnInit {
 
   strategies: string[] = []; // 存储所有策略名称
   selectedStrategy: string = ''; // 当前选中的策略
+  private summaryRequestInFlight = false;
+  private lastSummaryRequestKey = '';
 
   // 组合图表配置
   public combinedChartData: ChartConfiguration<'bar' | 'line'>['data'] = {
@@ -284,6 +286,15 @@ export class MessageDashboardComponent implements OnInit {
   }
 
   ngOnInit(): void {
+    if (!this.dateRange || this.dateRange.length < 2) {
+      const end = new Date();
+      end.setHours(23, 59, 59, 999);
+      const start = new Date(end);
+      start.setDate(end.getDate() - 6);
+      start.setHours(0, 0, 0, 0);
+      this.dateRange = [start, end];
+    }
+
     this.loadAllStatistics();
     this.loadStrategies();
   }
@@ -342,11 +353,18 @@ export class MessageDashboardComponent implements OnInit {
       .set('page', '1')
       .set('limit', '50');
 
+    const summaryRequestKey = params.toString();
+    if (this.summaryRequestInFlight && this.lastSummaryRequestKey === summaryRequestKey) {
+      return;
+    }
+
     // 首屏汇总数据:overall + dailyTrends + strategy(默认第一页)
     this.overallLoading = true;
     this.chartLoading = true;
     this.dailyTrendsLoading = true;
     this.strategyLoading = this.activeTab === 0;
+    this.summaryRequestInFlight = true;
+    this.lastSummaryRequestKey = summaryRequestKey;
     this.http
       .get(`/api/message/statistics/summary`, { params })
       .pipe(
@@ -361,6 +379,7 @@ export class MessageDashboardComponent implements OnInit {
           this.chartLoading = false;
           this.dailyTrendsLoading = false;
           this.strategyLoading = false;
+          this.summaryRequestInFlight = false;
         })
       )
       .subscribe((summary) => {

Vissa filer visades inte eftersom för många filer har ändrats