Преглед на файлове

消息统计接口优化

guoziyun преди 1 месец
родител
ревизия
76bb019d6e

+ 65 - 0
.github/copilot-instructions.md

@@ -0,0 +1,65 @@
+# Copilot Instructions
+
+This repository is a BI and LiveOps system for a coloring game. Optimize for data correctness, operational safety, and low-risk incremental changes.
+
+## Core Priorities
+
+- Prefer small, reversible changes over broad rewrites.
+- Preserve production behavior unless a task explicitly requires behavior changes.
+- Treat analytics ingestion, cron jobs, ClickHouse schema, MongoDB aggregation, and PM2 cutover scripts as high-risk areas.
+- Fix the narrowest correct slice first.
+- Do not refactor unrelated code while touching critical data paths.
+
+## Repository Guidance
+
+- `oms/` contains the Node.js + TypeScript backend, services, cron jobs, scripts, and admin APIs.
+- `omsapp/` contains the Angular admin dashboard source.
+- `oms/services/event-api-service.ts` receives analytics events and publishes to RabbitMQ.
+- `oms/services/log-service.ts` writes raw logs to rotating files.
+- `oms/services/ingestor-service.ts` consumes analytics events and writes to ClickHouse and MongoDB.
+- `oms/services/cron-jobs/done-rate.ts` is a known hotspot.
+- `oms/public/app/` contains built frontend assets. Prefer editing `omsapp/src/` instead of generated files.
+
+## Analytics and Data Rules
+
+- Prefer query-shape optimization before changing analytics semantics.
+- For ClickHouse changes, consider partition pruning, scanned time range, aggregation fanout, and whether multiple scans can be merged into one.
+- Use half-open time ranges: `[start, nextStart)`.
+- Add execution timing and result-size logs for long-running queries or cron jobs when useful.
+- The `events` table is partitioned monthly by `toYYYYMM(time)`. Preserve partition-aware query patterns.
+- `oms/services/ingestor-service.ts` currently accepts a small amount of analytics loss as a tradeoff. Do not change ack semantics unless the task explicitly targets ingestion reliability.
+
+## Operational Script Rules
+
+- Default scripts to safe behavior.
+- Prefer dry-run by default for destructive, migration, or cutover operations.
+- Print exact SQL or command plans before executing cutovers.
+- Prefer idempotent reconciliation logic keyed by stable identifiers such as `log_id`.
+- Prefer Dockerized database tooling when available. In this repo, use `docker exec ... clickhouse-client ...` rather than assuming `clickhouse-client` exists on the host.
+- Be careful not to restart unrelated PM2 services during operational changes.
+
+## Validation Expectations
+
+Prefer the narrowest useful validation in this order:
+
+1. File-level syntax or type validation
+2. Targeted script execution or query validation
+3. Focused runtime validation of the changed service or cron job
+4. Broader build only if needed
+
+For database or migration work:
+
+1. Validate counts before and after
+2. Validate per-month or per-partition distribution when relevant
+3. Keep an explicit rollback path
+
+## Tracking
+
+- Use `oms/OPTIMIZATION_TRACKER.md` as the source of truth for completed and pending optimization items.
+- When finishing a meaningful optimization, update that tracker.
+
+## When Unsure
+
+- Choose the safer operational path.
+- Prefer observability over speculation.
+- Add logs and narrow validation before making a larger follow-up change.

+ 120 - 0
AGENTS.md

@@ -0,0 +1,120 @@
+# AGENTS.md
+
+## Purpose
+
+This repository contains a BI and LiveOps system for a coloring game. Agents working in this repo should optimize for data correctness, operational safety, and low-risk incremental changes.
+
+Use this file as the default project behavior guide for future coding, debugging, and optimization work.
+
+## Repository Overview
+
+- `oms/`: Node.js + TypeScript backend, data services, cron jobs, admin APIs.
+- `omsapp/`: Angular admin dashboard.
+- `oms/services/event-api-service.ts`: receives analytics events and publishes to RabbitMQ.
+- `oms/services/log-service.ts`: consumes RabbitMQ events and writes rotating logs.
+- `oms/services/ingestor-service.ts`: consumes RabbitMQ events and writes to ClickHouse and MongoDB.
+- `oms/services/cron-jobs/done-rate.ts`: daily completion-rate aggregation job.
+- `oms/src/services/clickhouseService.ts`: ClickHouse table management and query wrapper.
+- `oms/scripts/`: operational and migration scripts.
+
+## Working Principles
+
+1. Prefer small, reversible changes over broad rewrites.
+2. Preserve production behavior unless the task explicitly requires a behavior change.
+3. Treat data scripts, cron jobs, and ingestion paths as high-risk surfaces.
+4. When changing analytics logic, explain the data impact clearly.
+5. When changing operational scripts, favor idempotent and restart-safe behavior.
+
+## High-Risk Areas
+
+Changes in these areas require extra care and focused validation:
+
+- RabbitMQ publish and consume paths
+- ClickHouse schema, partitioning, and migration scripts
+- MongoDB aggregation and batch update logic
+- Cron jobs that scan large datasets
+- PM2-managed services and cutover scripts
+
+## Default Behavior Expectations
+
+### Code Changes
+
+- Fix the narrowest correct slice first.
+- Do not refactor unrelated code while touching a critical data path.
+- Reuse existing service abstractions unless they are the root cause.
+- Keep public API and payload formats stable unless explicitly asked to change them.
+
+### Data and Analytics Changes
+
+- Prefer query-shape optimization before changing product semantics.
+- For ClickHouse queries, always consider:
+  - partition pruning
+  - scanned time range
+  - aggregation fanout
+  - whether multiple scans can be merged into one
+- For long-running jobs, add execution timing and result-size logs when useful.
+- Use half-open time ranges: `[start, nextStart)`.
+
+### Operational Scripts
+
+- Scripts should default to safe behavior.
+- Prefer dry-run by default for destructive or cutover actions.
+- Print the exact SQL or command plan before executing.
+- Avoid relying on host-installed database tools when the repo already uses Dockerized services.
+- When reconciling data, prefer idempotent logic keyed by stable identifiers such as `log_id`.
+
+### Frontend and Generated Assets
+
+- Do not manually edit built Angular assets under `oms/public/app/` unless explicitly asked.
+- Prefer editing source files under `omsapp/src/` and rebuilding.
+
+## Validation Rules
+
+After making changes, prefer the narrowest useful validation in this order:
+
+1. file-level type or syntax validation
+2. targeted script execution or query validation
+3. focused runtime check on the changed service or cron job
+4. broader build only if needed
+
+For database or migration work:
+
+1. validate counts before and after
+2. validate per-month or per-partition distribution when relevant
+3. keep an explicit rollback path
+
+## Environment Conventions
+
+- This project commonly runs ClickHouse in Docker.
+- Prefer `docker exec ... clickhouse-client ...` over assuming `clickhouse-client` exists on the host.
+- PM2 is used in production-like environments.
+- Be careful not to restart unrelated services during operational changes.
+
+## Current Project-Specific Guidance
+
+### Done Rate
+
+- `oms/services/cron-jobs/done-rate.ts` is a hotspot.
+- Keep ClickHouse aggregation consolidated when possible.
+- Watch both ClickHouse query time and MongoDB update time.
+
+### Ingestor Reliability
+
+- `oms/services/ingestor-service.ts` currently accepts a small amount of analytics loss as a tradeoff.
+- Do not change acknowledgement semantics unless the task explicitly targets ingestion reliability.
+
+### ClickHouse Storage
+
+- The `events` table is partitioned monthly by `toYYYYMM(time)`.
+- Future schema changes must preserve partition-aware query patterns.
+
+## Tracking and Follow-Up
+
+- Use `oms/OPTIMIZATION_TRACKER.md` as the source of truth for known completed and pending optimizations.
+- When finishing a meaningful optimization, update that tracker.
+
+## When Unsure
+
+- Choose the safer operational path.
+- Prefer observability over speculation.
+- Add logs and narrow validation before making a second larger change.

+ 32 - 0
oms/OPTIMIZATION_TRACKER.md

@@ -110,6 +110,38 @@
   - 区分客户端错误与系统拥塞。
   - 评估本地丢弃、采样或重试策略。
 
+### 6. 消息统计接口超时(Phase 1 执行中)
+
+- 状态: `待修正`
+- 阶段: `Phase 1 进行中`
+- 文件:
+  - `src/controllers/messageRecordController.ts`
+  - `src/services/messageRecordService.ts`
+  - `src/models/messageRecordModel.ts`
+  - `omsapp/src/app/pages/message-dashboard.component.ts`
+- 背景:
+  - `api/message/statistics/overall`
+  - `api/message/statistics/daily-trends`
+  - `api/message/statistics/by-strategy`
+  - 等接口在大时间窗口下易超时。
+- 当前阶段目标:
+  - 核心统计接口 p95 <= 2 秒。
+  - 首屏加载不触发所有重接口。
+  - 预聚合放在 Phase 1.5。
+- 本轮已落地:
+  - 聚合查询统一增加 `allowDiskUse(true)`。
+  - 维度统计接口新增分页参数(page/limit)并启用安全上限。
+  - controller 增加日期范围上限校验和统一错误响应(`RANGE_TOO_LARGE`)。
+  - controller 增加结构化统计日志(请求范围、耗时、结果规模、错误信息)。
+  - 核心接口(`overall`、`daily-trends`、`by-strategy`)接入 Redis 缓存(TTL 300s)并输出命中日志。
+  - 新增汇总接口 `api/message/statistics/summary`,首屏合并返回 overall + daily-trends + by-strategy(默认分页)。
+  - 前端首屏加载改为优先请求 `summary`,减少首屏多接口并发重查询。
+  - `api/message-records` 列表查询在无筛选场景改为 `estimatedDocumentCount`(替代全量 `countDocuments`),并增加排序白名单与 `lean()` 返回,缓解千万级数据下列表超时。
+- 下一步:
+  - 增加缓存失效策略与命中率监控(Phase 1)。
+  - 补充 summary 与各维度接口的基准压测数据(Phase 1)。
+  - 预聚合数据源切换(Phase 1.5)。
+
 ### 5. 配置与凭证仍混在代码和部署配置中
 
 - 状态: `待修正`

+ 153 - 22
oms/dist/src/controllers/messageRecordController.js

@@ -4,6 +4,62 @@ const mongoose_1 = require("mongoose");
 const messageRecordModel_1 = require("../models/messageRecordModel");
 const messageRecordService_1 = require("../services/messageRecordService");
 class MessageRecordController {
+    parseDateRange(req) {
+        const { startDate, endDate } = req.query;
+        const start = startDate ? new Date(startDate) : undefined;
+        const end = endDate ? new Date(endDate) : undefined;
+        return { start, end };
+    }
+    validateDateRange(res, start, end, maxDays = MessageRecordController.MAX_RANGE_DAYS_GENERAL) {
+        if (!start || !end) {
+            return true;
+        }
+        if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+            res.status(400).json({ success: false, message: "Invalid startDate/endDate format." });
+            return false;
+        }
+        const requestedRangeDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
+        if (requestedRangeDays > maxDays) {
+            res.status(422).json({
+                success: false,
+                code: "RANGE_TOO_LARGE",
+                message: `Requested date range exceeds max allowed ${maxDays} days.`,
+                maxRangeDays: maxDays,
+                requestedRangeDays,
+                suggestedAction: "Narrow date range or apply more filters.",
+            });
+            return false;
+        }
+        return true;
+    }
+    parsePagination(req) {
+        const pageRaw = parseInt(req.query.page, 10);
+        const limitRaw = parseInt(req.query.limit, 10);
+        const page = Number.isNaN(pageRaw) ? messageRecordService_1.MessageRecordService.DEFAULT_STATS_PAGE : Math.max(1, pageRaw);
+        const limit = Number.isNaN(limitRaw) ? messageRecordService_1.MessageRecordService.DEFAULT_STATS_LIMIT : Math.min(messageRecordService_1.MessageRecordService.MAX_STATS_LIMIT, Math.max(1, limitRaw));
+        return { page, limit };
+    }
+    logStatsRequest(endpoint, req, extra = {}) {
+        const startedAt = Date.now();
+        console.log(`[MessageStats] ${endpoint} start`, JSON.stringify({
+            endpoint,
+            startDate: req.query.startDate || null,
+            endDate: req.query.endDate || null,
+            strategyName: req.query.strategyName || null,
+            page: req.query.page || null,
+            limit: req.query.limit || null,
+            ...extra,
+        }));
+        return (resultRows, success, errorMessage) => {
+            console.log(`[MessageStats] ${endpoint} end`, JSON.stringify({
+                endpoint,
+                durationMs: Date.now() - startedAt,
+                resultRows,
+                success,
+                errorMessage: errorMessage || null,
+            }));
+        };
+    }
     constructor() {
         /**
          * @route POST /api/message-record
@@ -46,7 +102,13 @@ class MessageRecordController {
                 return res.status(200).json({
                     success: true,
                     data: paginatedRecords.records,
-                    pagination: { total: paginatedRecords.total, page: paginatedRecords.page, limit: paginatedRecords.limit, totalPages: paginatedRecords.totalPages },
+                    pagination: {
+                        total: paginatedRecords.total,
+                        page: paginatedRecords.page,
+                        limit: paginatedRecords.limit,
+                        totalPages: paginatedRecords.totalPages,
+                        isTotalEstimated: paginatedRecords.isTotalEstimated,
+                    },
                 });
             }
             catch (error) {
@@ -126,20 +188,52 @@ class MessageRecordController {
          */
         this.getOverallStatistics = async (req, res) => {
             try {
-                console.time("getOverallStatistics");
-                const { startDate, endDate, strategyName } = req.query;
-                const start = startDate ? new Date(startDate) : undefined;
-                const end = endDate ? new Date(endDate) : undefined;
+                const done = this.logStatsRequest("overall", req);
+                const { start, end } = this.parseDateRange(req);
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+                    return res;
+                }
+                const { strategyName } = req.query;
                 const stratName = strategyName ? strategyName : undefined;
                 const stats = await this.messageRecordService.getOverallStatistics(start, end, stratName);
-                console.timeEnd("getOverallStatistics");
+                done(stats ? 1 : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("overall", req);
+                done(0, false, error.message);
                 console.error("Error fetching overall statistics:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
         };
+        /**
+         * @route GET /api/message/statistics/summary
+         * @desc Retrieves first-screen summary payload for message dashboard.
+         * @access Private
+         */
+        this.getStatisticsSummary = async (req, res) => {
+            try {
+                const done = this.logStatsRequest("summary", req);
+                const { start, end } = this.parseDateRange(req);
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+                    return res;
+                }
+                const { strategyName } = req.query;
+                const stratName = strategyName ? strategyName : undefined;
+                const { page, limit } = this.parsePagination(req);
+                const summary = await this.messageRecordService.getStatisticsSummary(start, end, stratName, page, limit);
+                const trendCount = Array.isArray(summary.dailyTrends) ? summary.dailyTrends.length : 0;
+                const strategyCount = Array.isArray(summary.strategyStats) ? summary.strategyStats.length : 0;
+                done(trendCount + strategyCount, true);
+                return res.status(200).json({ success: true, data: summary });
+            }
+            catch (error) {
+                const done = this.logStatsRequest("summary", req);
+                done(0, false, error.message);
+                console.error("Error fetching statistics summary:", error);
+                return res.status(500).json({ success: false, message: "Server error", error: error.message });
+            }
+        };
         /**
          * @route GET /api/message/statistics/by-activity
          * @desc Retrieves message push statistics grouped by activity
@@ -169,16 +263,22 @@ class MessageRecordController {
          */
         this.getStatisticsByStrategy = async (req, res) => {
             try {
-                console.time("getStatisticsByStrategy");
+                const done = this.logStatsRequest("by-strategy", req);
                 const { startDate, endDate, strategyName } = req.query;
                 const start = startDate ? new Date(startDate) : undefined;
                 const end = endDate ? new Date(endDate) : undefined;
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+                    return res;
+                }
                 const stratName = strategyName ? strategyName : undefined;
-                const stats = await this.messageRecordService.getStatisticsByStrategy(start, end, stratName);
-                console.timeEnd("getStatisticsByStrategy");
+                const { page, limit } = this.parsePagination(req);
+                const stats = await this.messageRecordService.getStatisticsByStrategy(start, end, stratName, page, limit);
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("by-strategy", req);
+                done(0, false, error.message);
                 console.error("Error fetching statistics by strategy:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -191,17 +291,23 @@ class MessageRecordController {
          */
         this.getStatisticsByTemplate = async (req, res) => {
             try {
-                console.time("getStatisticsByTemplate");
+                const done = this.logStatsRequest("by-template", req);
                 // 从查询参数中获取 startDate, endDate 和 strategyName
                 const { startDate, endDate, strategyName } = req.query;
                 const start = startDate ? new Date(startDate) : undefined;
                 const end = endDate ? new Date(endDate) : undefined;
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+                    return res;
+                }
                 const stratName = strategyName ? strategyName : undefined;
-                const stats = await this.messageRecordService.getStatisticsByTemplate(start, end, stratName);
-                console.timeEnd("getStatisticsByTemplate");
+                const { page, limit } = this.parsePagination(req);
+                const stats = await this.messageRecordService.getStatisticsByTemplate(start, end, stratName, page, limit);
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("by-template", req);
+                done(0, false, error.message);
                 console.error("Error fetching statistics by template:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -214,17 +320,23 @@ class MessageRecordController {
          */
         this.getStatisticsByCc = async (req, res) => {
             try {
-                console.time("getStatisticsByCc");
+                const done = this.logStatsRequest("by-cc", req);
                 // 从查询参数中获取 startDate, endDate 和 strategyName
                 const { startDate, endDate, strategyName } = req.query;
                 const start = startDate ? new Date(startDate) : undefined;
                 const end = endDate ? new Date(endDate) : undefined;
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+                    return res;
+                }
                 const stratName = strategyName ? strategyName : undefined;
-                const stats = await this.messageRecordService.getStatisticsByCc(start, end, stratName);
-                console.timeEnd("getStatisticsByCc");
+                const { page, limit } = this.parsePagination(req);
+                const stats = await this.messageRecordService.getStatisticsByCc(start, end, stratName, page, limit);
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("by-cc", req);
+                done(0, false, error.message);
                 console.error("Error fetching statistics by cc:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -237,17 +349,23 @@ class MessageRecordController {
          */
         this.getStatisticsByImage = async (req, res) => {
             try {
-                console.time("getStatisticsByImage");
+                const done = this.logStatsRequest("by-image", req);
                 // 从查询参数中获取 startDate, endDate 和 strategyName
                 const { startDate, endDate, strategyName } = req.query;
                 const start = startDate ? new Date(startDate) : undefined;
                 const end = endDate ? new Date(endDate) : undefined;
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+                    return res;
+                }
                 const stratName = strategyName ? strategyName : undefined;
-                const stats = await this.messageRecordService.getStatisticsByImage(start, end, stratName);
-                console.timeEnd("getStatisticsByImage");
+                const { page, limit } = this.parsePagination(req);
+                const stats = await this.messageRecordService.getStatisticsByImage(start, end, stratName, page, limit);
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("by-image", req);
+                done(0, false, error.message);
                 console.error("Error fetching statistics by image:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -260,17 +378,22 @@ class MessageRecordController {
          */
         this.getDailySentTrends = async (req, res) => {
             try {
-                console.time("getDailySentTrends");
+                const done = this.logStatsRequest("daily-trends", req);
                 // 从查询参数中获取 startDate, endDate 和 strategyName
                 const { startDate, endDate, strategyName } = req.query;
                 const start = startDate ? new Date(startDate) : undefined;
                 const end = endDate ? new Date(endDate) : undefined;
+                if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+                    return res;
+                }
                 const stratName = strategyName ? strategyName : undefined;
                 const stats = await this.messageRecordService.getDailySentTrends(start, end, stratName);
-                console.timeEnd("getDailySentTrends");
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("daily-trends", req);
+                done(0, false, error.message);
                 console.error("Error fetching daily sent trends:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -283,13 +406,16 @@ class MessageRecordController {
          */
         this.getMultiDimensionalStatistics = async (req, res) => {
             try {
-                console.time("getMultiDimensionalStatistics");
+                const done = this.logStatsRequest("multi-dimensional", req);
                 const { startDate, endDate, templateName, strategyName, cc, image } = req.query;
                 const filters = {};
                 if (startDate)
                     filters.startDate = new Date(startDate);
                 if (endDate)
                     filters.endDate = new Date(endDate);
+                if (!this.validateDateRange(res, filters.startDate, filters.endDate, MessageRecordController.MAX_RANGE_DAYS_MULTI_DIMENSION)) {
+                    return res;
+                }
                 if (templateName)
                     filters.templateName = templateName;
                 if (strategyName)
@@ -299,10 +425,12 @@ class MessageRecordController {
                 if (image)
                     filters.image = image;
                 const stats = await this.messageRecordService.getMultiDimensionalStatistics(filters);
-                console.timeEnd("getMultiDimensionalStatistics");
+                done(Array.isArray(stats) ? stats.length : 0, true);
                 return res.status(200).json({ success: true, data: stats });
             }
             catch (error) {
+                const done = this.logStatsRequest("multi-dimensional", req);
+                done(0, false, error.message);
                 console.error("Error fetching multi-dimensional statistics:", error);
                 return res.status(500).json({ success: false, message: "Server error", error: error.message });
             }
@@ -417,4 +545,7 @@ class MessageRecordController {
         this.messageRecordService = new messageRecordService_1.MessageRecordService();
     }
 }
+MessageRecordController.MAX_RANGE_DAYS_GENERAL = 180;
+MessageRecordController.MAX_RANGE_DAYS_DIMENSION = 90;
+MessageRecordController.MAX_RANGE_DAYS_MULTI_DIMENSION = 31;
 exports.default = new MessageRecordController();

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

@@ -21,6 +21,7 @@ router.post("/admin/login", adminController_1.default.login);
 router.post("/users/send-message", userController_1.default.sendDirectMessage); // 点对点
 // 新增:消息记录统计路由
 router.get("/message/statistics/overall", messageRecordController_1.default.getOverallStatistics);
+router.get("/message/statistics/summary", messageRecordController_1.default.getStatisticsSummary);
 router.get("/message/statistics/by-activity", messageRecordController_1.default.getStatisticsByActivity);
 router.get("/message/statistics/by-strategy", messageRecordController_1.default.getStatisticsByStrategy);
 router.get("/message/statistics/by-template", messageRecordController_1.default.getStatisticsByTemplate);

+ 150 - 22
oms/dist/src/services/messageRecordService.js

@@ -2,6 +2,7 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.MessageRecordService = void 0;
 const messageRecordModel_1 = require("../models/messageRecordModel");
+const clients_1 = require("./clients");
 class MessageRecordService {
     /**
      * 创建一条新的消息推送记录
@@ -16,6 +17,8 @@ class MessageRecordService {
      * 分页获取消息推送记录,支持筛选和排序
      */
     async getPaginatedRecords(page = MessageRecordService.DEFAULT_PAGE, limit = MessageRecordService.DEFAULT_LIMIT, filters = {}, sortField = MessageRecordService.DEFAULT_SORT_FIELD, sortOrder = MessageRecordService.DEFAULT_SORT_ORDER) {
+        const safePage = Number.isNaN(page) ? MessageRecordService.DEFAULT_PAGE : Math.max(1, page);
+        const safeLimit = Number.isNaN(limit) ? MessageRecordService.DEFAULT_LIMIT : Math.min(MessageRecordService.MAX_LIMIT, Math.max(1, limit));
         // 构建查询条件
         const query = this.buildDateFilterQuery(filters);
         // 添加非日期筛选条件
@@ -27,15 +30,23 @@ class MessageRecordService {
             query.templateName = filters.templateName;
         if (filters.status !== undefined)
             query.status = filters.status;
+        const safeSortField = MessageRecordService.ALLOWED_SORT_FIELDS.has(sortField)
+            ? sortField
+            : MessageRecordService.DEFAULT_SORT_FIELD;
         const sortOption = {};
-        sortOption[sortField] = sortOrder === "asc" ? 1 : -1;
-        const total = await messageRecordModel_1.MessageRecord.countDocuments(query);
+        sortOption[safeSortField] = sortOrder === "asc" ? 1 : -1;
+        const isUnfilteredQuery = Object.keys(query).length === 0;
+        const total = isUnfilteredQuery
+            ? await messageRecordModel_1.MessageRecord.estimatedDocumentCount()
+            : await messageRecordModel_1.MessageRecord.countDocuments(query);
+        const isTotalEstimated = isUnfilteredQuery;
         const records = await messageRecordModel_1.MessageRecord.find(query)
             .sort(sortOption)
-            .skip((page - 1) * limit)
-            .limit(limit);
-        const totalPages = Math.ceil(total / limit);
-        return { records, total, page, limit, totalPages };
+            .skip((safePage - 1) * safeLimit)
+            .limit(safeLimit)
+            .lean();
+        const totalPages = Math.ceil(total / safeLimit);
+        return { records, total, page: safePage, limit: safeLimit, totalPages, isTotalEstimated };
     }
     /**
      * 根据用户UID获取其所有消息推送记录
@@ -65,54 +76,115 @@ class MessageRecordService {
      * 获取整体消息推送统计数据(含用户点击率)
      */
     async getOverallStatistics(startDate, endDate, strategyName) {
+        const cacheKey = this.buildStatsCacheKey("overall", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        return this.getStatisticsByGroup(matchConditions, []);
+        const result = await this.getStatisticsByGroup(matchConditions, []);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
     }
     /**
      * 按活动获取消息统计数据(含用户点击率)
      */
-    async getStatisticsByActivity(startDate, endDate) {
+    async getStatisticsByActivity(startDate, endDate, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         const matchConditions = this.buildMatchConditions(startDate, endDate);
         const groupFields = ["activityId", "activityName"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { deliveredRate: -1 });
+        return this.getStatisticsByGroup(matchConditions, groupFields, { deliveredRate: -1 }, page, limit);
     }
     /**
      * 按策略获取消息统计数据(含用户点击率)
      */
-    async getStatisticsByStrategy(startDate, endDate, strategyName) {
+    async getStatisticsByStrategy(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
+        const cacheKey = this.buildStatsCacheKey("by-strategy", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+            page,
+            limit,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
         const groupFields = ["strategyId", "strategyName"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+        const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
     }
     /**
      * 按模板获取消息统计数据(含用户点击率)
      */
-    async getStatisticsByTemplate(startDate, endDate, strategyName) {
+    async getStatisticsByTemplate(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
         const groupFields = ["templateId", "templateName"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
     }
     /**
      * 按国家代码获取消息统计数据(含用户点击率)
      */
-    async getStatisticsByCc(startDate, endDate, strategyName) {
+    async getStatisticsByCc(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
         const groupFields = ["cc"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 });
+        return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 }, page, limit);
     }
     /**
      * 按图片 URL 获取消息统计数据(含用户点击率)
      */
-    async getStatisticsByImage(startDate, endDate, strategyName) {
+    async getStatisticsByImage(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
         const groupFields = ["image"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
     }
     /**
      * 按时间维度的趋势分析,每日统计(含用户点击率)
      */
     async getDailySentTrends(startDate, endDate, strategyName) {
-        return this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+        const cacheKey = this.buildStatsCacheKey("daily-trends", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
+        const result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
+    }
+    /**
+     * 获取消息统计首屏汇总数据。
+     * 用于首屏减少多次并行重查询。
+     */
+    async getStatisticsSummary(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
+        const [overall, dailyTrends, strategyStats] = await Promise.all([
+            this.getOverallStatistics(startDate, endDate, strategyName),
+            this.getDailySentTrends(startDate, endDate, strategyName),
+            this.getStatisticsByStrategy(startDate, endDate, strategyName, page, limit),
+        ]);
+        return {
+            overall,
+            dailyTrends,
+            strategyStats,
+            pagination: {
+                page,
+                limit,
+            },
+        };
     }
     /**
      * 按国家代码和时间维度获取每日统计数据(含用户点击率)
@@ -194,7 +266,7 @@ class MessageRecordService {
             pipeline.push({
                 $sort: { date: 1, templateName: 1, cc: 1, image: 1 },
             });
-            return await messageRecordModel_1.MessageRecord.aggregate(pipeline);
+            return await messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true);
         }
         catch (error) {
             console.error("Error fetching multi-dimensional statistics:", error);
@@ -342,7 +414,7 @@ class MessageRecordService {
     /**
      * 按分组获取统计数据的通用方法(支持用户点击率)
      */
-    async getStatisticsByGroup(matchConditions, groupFields = [], sortOrder = { _id: 1 }) {
+    async getStatisticsByGroup(matchConditions, groupFields = [], sortOrder = { _id: 1 }, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         try {
             const pipeline = [];
             // 添加匹配阶段
@@ -386,7 +458,13 @@ class MessageRecordService {
             });
             // 添加排序
             pipeline.push({ $sort: sortOrder });
-            const results = await messageRecordModel_1.MessageRecord.aggregate(pipeline);
+            if (groupFields.length > 0) {
+                const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+                const safeLimit = Math.min(MessageRecordService.MAX_STATS_LIMIT, Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT)));
+                pipeline.push({ $skip: (safePage - 1) * safeLimit });
+                pipeline.push({ $limit: safeLimit });
+            }
+            const results = await messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true);
             return groupFields.length === 0 ? results[0] : results;
         }
         catch (error) {
@@ -473,13 +551,49 @@ class MessageRecordService {
             pipeline.push({
                 $sort: { date: -1 },
             });
-            return await messageRecordModel_1.MessageRecord.aggregate(pipeline);
+            return await messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true);
         }
         catch (error) {
             console.error("Error fetching daily trends:", error);
             return [];
         }
     }
+    buildStatsCacheKey(endpoint, params) {
+        const normalized = {};
+        Object.keys(params)
+            .sort()
+            .forEach((key) => {
+            normalized[key] = params[key] === undefined ? null : params[key];
+        });
+        return `message_stats:${endpoint}:${JSON.stringify(normalized)}`;
+    }
+    async getCache(key) {
+        try {
+            if (!clients_1.redisClient.isOpen) {
+                return null;
+            }
+            const cached = await clients_1.redisClient.get(key);
+            if (!cached) {
+                return null;
+            }
+            return JSON.parse(cached);
+        }
+        catch (error) {
+            console.warn(`[MessageStatsCache] get failed key=${key}`, error);
+            return null;
+        }
+    }
+    async setCache(key, value) {
+        try {
+            if (!clients_1.redisClient.isOpen) {
+                return;
+            }
+            await clients_1.redisClient.setEx(key, MessageRecordService.STATS_CACHE_TTL_SECONDS, JSON.stringify(value));
+        }
+        catch (error) {
+            console.warn(`[MessageStatsCache] set failed key=${key}`, error);
+        }
+    }
 }
 exports.MessageRecordService = MessageRecordService;
 // 提取常量便于维护
@@ -487,5 +601,19 @@ MessageRecordService.TIMEZONE = "America/Los_Angeles"; // UTC+8时区
 // private static readonly TIMEZONE = "Asia/Shanghai"; // UTC+8时区
 MessageRecordService.DEFAULT_PAGE = 1;
 MessageRecordService.DEFAULT_LIMIT = 10;
+MessageRecordService.MAX_LIMIT = 100;
 MessageRecordService.DEFAULT_SORT_FIELD = "createdAt";
 MessageRecordService.DEFAULT_SORT_ORDER = "desc";
+MessageRecordService.ALLOWED_SORT_FIELDS = new Set([
+    "createdAt",
+    "updatedAt",
+    "status",
+    "uid",
+    "strategyName",
+    "templateName",
+    "activityName",
+]);
+MessageRecordService.DEFAULT_STATS_PAGE = 1;
+MessageRecordService.DEFAULT_STATS_LIMIT = 50;
+MessageRecordService.MAX_STATS_LIMIT = 200;
+MessageRecordService.STATS_CACHE_TTL_SECONDS = 300;

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

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
oms/public/app/main-DG6NCAZH.js


+ 173 - 22
oms/src/controllers/messageRecordController.ts

@@ -6,6 +6,81 @@ import { MessageRecordService } from "../services/messageRecordService";
 class MessageRecordController {
   private messageRecordService: MessageRecordService;
 
+  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 parseDateRange(req: Request) {
+    const { startDate, endDate } = req.query;
+    const start = startDate ? new Date(startDate as string) : undefined;
+    const end = endDate ? new Date(endDate as string) : undefined;
+    return { start, end };
+  }
+
+  private validateDateRange(res: Response, start?: Date, end?: Date, maxDays: number = MessageRecordController.MAX_RANGE_DAYS_GENERAL): boolean {
+    if (!start || !end) {
+      return true;
+    }
+
+    if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+      res.status(400).json({ success: false, message: "Invalid startDate/endDate format." });
+      return false;
+    }
+
+    const requestedRangeDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
+
+    if (requestedRangeDays > maxDays) {
+      res.status(422).json({
+        success: false,
+        code: "RANGE_TOO_LARGE",
+        message: `Requested date range exceeds max allowed ${maxDays} days.`,
+        maxRangeDays: maxDays,
+        requestedRangeDays,
+        suggestedAction: "Narrow date range or apply more filters.",
+      });
+      return false;
+    }
+
+    return true;
+  }
+
+  private parsePagination(req: Request) {
+    const pageRaw = parseInt(req.query.page as string, 10);
+    const limitRaw = parseInt(req.query.limit as string, 10);
+    const page = Number.isNaN(pageRaw) ? MessageRecordService.DEFAULT_STATS_PAGE : Math.max(1, pageRaw);
+    const limit = Number.isNaN(limitRaw) ? MessageRecordService.DEFAULT_STATS_LIMIT : Math.min(MessageRecordService.MAX_STATS_LIMIT, Math.max(1, limitRaw));
+    return { page, limit };
+  }
+
+  private logStatsRequest(endpoint: string, req: Request, extra: Record<string, unknown> = {}) {
+    const startedAt = Date.now();
+    console.log(
+      `[MessageStats] ${endpoint} start`,
+      JSON.stringify({
+        endpoint,
+        startDate: req.query.startDate || null,
+        endDate: req.query.endDate || null,
+        strategyName: req.query.strategyName || null,
+        page: req.query.page || null,
+        limit: req.query.limit || null,
+        ...extra,
+      })
+    );
+
+    return (resultRows: number, success: boolean, errorMessage?: string) => {
+      console.log(
+        `[MessageStats] ${endpoint} end`,
+        JSON.stringify({
+          endpoint,
+          durationMs: Date.now() - startedAt,
+          resultRows,
+          success,
+          errorMessage: errorMessage || null,
+        })
+      );
+    };
+  }
+
   constructor() {
     this.messageRecordService = new MessageRecordService();
   }
@@ -60,7 +135,13 @@ class MessageRecordController {
       return res.status(200).json({
         success: true,
         data: paginatedRecords.records,
-        pagination: { total: paginatedRecords.total, page: paginatedRecords.page, limit: paginatedRecords.limit, totalPages: paginatedRecords.totalPages },
+        pagination: {
+          total: paginatedRecords.total,
+          page: paginatedRecords.page,
+          limit: paginatedRecords.limit,
+          totalPages: paginatedRecords.totalPages,
+          isTotalEstimated: paginatedRecords.isTotalEstimated,
+        },
       });
     } catch (error: any) {
       console.error("Error fetching paginated records:", error);
@@ -140,22 +221,58 @@ class MessageRecordController {
    */
   public getOverallStatistics = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getOverallStatistics");
-      const { startDate, endDate, strategyName } = req.query;
-      const start = startDate ? new Date(startDate as string) : undefined;
-      const end = endDate ? new Date(endDate as string) : undefined;
+      const done = this.logStatsRequest("overall", req);
+      const { start, end } = this.parseDateRange(req);
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+        return res;
+      }
+
+      const { strategyName } = req.query;
       const stratName = strategyName ? (strategyName as string) : undefined;
 
       const stats = await this.messageRecordService.getOverallStatistics(start, end, stratName);
-      console.timeEnd("getOverallStatistics");
+      done(stats ? 1 : 0, true);
 
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("overall", req);
+      done(0, false, error.message);
       console.error("Error fetching overall statistics:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
   };
 
+  /**
+   * @route GET /api/message/statistics/summary
+   * @desc Retrieves first-screen summary payload for message dashboard.
+   * @access Private
+   */
+  public getStatisticsSummary = async (req: Request, res: Response): Promise<Response> => {
+    try {
+      const done = this.logStatsRequest("summary", req);
+      const { start, end } = this.parseDateRange(req);
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+        return res;
+      }
+
+      const { strategyName } = req.query;
+      const stratName = strategyName ? (strategyName as string) : undefined;
+      const { page, limit } = this.parsePagination(req);
+
+      const summary = await this.messageRecordService.getStatisticsSummary(start, end, stratName, page, limit);
+      const trendCount = Array.isArray(summary.dailyTrends) ? summary.dailyTrends.length : 0;
+      const strategyCount = Array.isArray(summary.strategyStats) ? summary.strategyStats.length : 0;
+      done(trendCount + strategyCount, true);
+
+      return res.status(200).json({ success: true, data: summary });
+    } catch (error: any) {
+      const done = this.logStatsRequest("summary", req);
+      done(0, false, error.message);
+      console.error("Error fetching statistics summary:", error);
+      return res.status(500).json({ success: false, message: "Server error", error: error.message });
+    }
+  };
+
   /**
    * @route GET /api/message/statistics/by-activity
    * @desc Retrieves message push statistics grouped by activity
@@ -186,15 +303,21 @@ class MessageRecordController {
    */
   public getStatisticsByStrategy = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getStatisticsByStrategy");
+      const done = this.logStatsRequest("by-strategy", req);
       const { startDate, endDate, strategyName } = req.query;
       const start = startDate ? new Date(startDate as string) : undefined;
       const end = endDate ? new Date(endDate as string) : undefined;
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+        return res;
+      }
       const stratName = strategyName ? (strategyName as string) : undefined;
-      const stats = await this.messageRecordService.getStatisticsByStrategy(start, end, stratName);
-      console.timeEnd("getStatisticsByStrategy");
+      const { page, limit } = this.parsePagination(req);
+      const stats = await this.messageRecordService.getStatisticsByStrategy(start, end, stratName, page, limit);
+      done(Array.isArray(stats) ? stats.length : 0, true);
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("by-strategy", req);
+      done(0, false, error.message);
       console.error("Error fetching statistics by strategy:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
@@ -208,16 +331,22 @@ class MessageRecordController {
    */
   public getStatisticsByTemplate = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getStatisticsByTemplate");
+      const done = this.logStatsRequest("by-template", req);
       // 从查询参数中获取 startDate, endDate 和 strategyName
       const { startDate, endDate, strategyName } = req.query;
       const start = startDate ? new Date(startDate as string) : undefined;
       const end = endDate ? new Date(endDate as string) : undefined;
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+        return res;
+      }
       const stratName = strategyName ? (strategyName as string) : undefined;
-      const stats = await this.messageRecordService.getStatisticsByTemplate(start, end, stratName);
-      console.timeEnd("getStatisticsByTemplate");
+      const { page, limit } = this.parsePagination(req);
+      const stats = await this.messageRecordService.getStatisticsByTemplate(start, end, stratName, page, limit);
+      done(Array.isArray(stats) ? stats.length : 0, true);
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("by-template", req);
+      done(0, false, error.message);
       console.error("Error fetching statistics by template:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
@@ -231,16 +360,22 @@ class MessageRecordController {
    */
   public getStatisticsByCc = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getStatisticsByCc");
+      const done = this.logStatsRequest("by-cc", req);
       // 从查询参数中获取 startDate, endDate 和 strategyName
       const { startDate, endDate, strategyName } = req.query;
       const start = startDate ? new Date(startDate as string) : undefined;
       const end = endDate ? new Date(endDate as string) : undefined;
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+        return res;
+      }
       const stratName = strategyName ? (strategyName as string) : undefined;
-      const stats = await this.messageRecordService.getStatisticsByCc(start, end, stratName);
-      console.timeEnd("getStatisticsByCc");
+      const { page, limit } = this.parsePagination(req);
+      const stats = await this.messageRecordService.getStatisticsByCc(start, end, stratName, page, limit);
+      done(Array.isArray(stats) ? stats.length : 0, true);
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("by-cc", req);
+      done(0, false, error.message);
       console.error("Error fetching statistics by cc:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
@@ -254,16 +389,22 @@ class MessageRecordController {
    */
   public getStatisticsByImage = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getStatisticsByImage");
+      const done = this.logStatsRequest("by-image", req);
       // 从查询参数中获取 startDate, endDate 和 strategyName
       const { startDate, endDate, strategyName } = req.query;
       const start = startDate ? new Date(startDate as string) : undefined;
       const end = endDate ? new Date(endDate as string) : undefined;
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_DIMENSION)) {
+        return res;
+      }
       const stratName = strategyName ? (strategyName as string) : undefined;
-      const stats = await this.messageRecordService.getStatisticsByImage(start, end, stratName);
-      console.timeEnd("getStatisticsByImage");
+      const { page, limit } = this.parsePagination(req);
+      const stats = await this.messageRecordService.getStatisticsByImage(start, end, stratName, page, limit);
+      done(Array.isArray(stats) ? stats.length : 0, true);
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("by-image", req);
+      done(0, false, error.message);
       console.error("Error fetching statistics by image:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
@@ -277,16 +418,21 @@ class MessageRecordController {
    */
   public getDailySentTrends = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getDailySentTrends");
+      const done = this.logStatsRequest("daily-trends", req);
       // 从查询参数中获取 startDate, endDate 和 strategyName
       const { startDate, endDate, strategyName } = req.query;
       const start = startDate ? new Date(startDate as string) : undefined;
       const end = endDate ? new Date(endDate as string) : undefined;
+      if (!this.validateDateRange(res, start, end, MessageRecordController.MAX_RANGE_DAYS_GENERAL)) {
+        return res;
+      }
       const stratName = strategyName ? (strategyName as string) : undefined;
       const stats = await this.messageRecordService.getDailySentTrends(start, end, stratName);
-      console.timeEnd("getDailySentTrends");
+      done(Array.isArray(stats) ? stats.length : 0, true);
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("daily-trends", req);
+      done(0, false, error.message);
       console.error("Error fetching daily sent trends:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }
@@ -300,22 +446,27 @@ class MessageRecordController {
    */
   public getMultiDimensionalStatistics = async (req: Request, res: Response): Promise<Response> => {
     try {
-      console.time("getMultiDimensionalStatistics");
+      const done = this.logStatsRequest("multi-dimensional", req);
       const { startDate, endDate, templateName, strategyName, cc, image } = req.query;
 
       const filters: { [key: string]: any } = {};
       if (startDate) filters.startDate = new Date(startDate as string);
       if (endDate) filters.endDate = new Date(endDate as string);
+      if (!this.validateDateRange(res, filters.startDate, filters.endDate, MessageRecordController.MAX_RANGE_DAYS_MULTI_DIMENSION)) {
+        return res;
+      }
       if (templateName) filters.templateName = templateName;
       if (strategyName) filters.strategyName = strategyName;
       if (cc) filters.cc = cc;
       if (image) filters.image = image;
 
       const stats = await this.messageRecordService.getMultiDimensionalStatistics(filters);
-      console.timeEnd("getMultiDimensionalStatistics");
+      done(Array.isArray(stats) ? stats.length : 0, true);
 
       return res.status(200).json({ success: true, data: stats });
     } catch (error: any) {
+      const done = this.logStatsRequest("multi-dimensional", req);
+      done(0, false, error.message);
       console.error("Error fetching multi-dimensional statistics:", error);
       return res.status(500).json({ success: false, message: "Server error", error: error.message });
     }

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

@@ -20,6 +20,7 @@ router.post("/users/send-message", userController.sendDirectMessage); // 点对
 
 // 新增:消息记录统计路由
 router.get("/message/statistics/overall", messageRecordController.getOverallStatistics);
+router.get("/message/statistics/summary", messageRecordController.getStatisticsSummary);
 router.get("/message/statistics/by-activity", messageRecordController.getStatisticsByActivity);
 router.get("/message/statistics/by-strategy", messageRecordController.getStatisticsByStrategy);
 router.get("/message/statistics/by-template", messageRecordController.getStatisticsByTemplate);

+ 205 - 23
oms/src/services/messageRecordService.ts

@@ -1,4 +1,5 @@
 import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+import { redisClient } from "./clients";
 
 export class MessageRecordService {
   // 提取常量便于维护
@@ -6,8 +7,23 @@ export class MessageRecordService {
   // private static readonly TIMEZONE = "Asia/Shanghai"; // UTC+8时区
   private static readonly DEFAULT_PAGE = 1;
   private static readonly DEFAULT_LIMIT = 10;
+  private static readonly MAX_LIMIT = 100;
   private static readonly DEFAULT_SORT_FIELD = "createdAt";
   private static readonly DEFAULT_SORT_ORDER: "asc" | "desc" = "desc";
+  private static readonly ALLOWED_SORT_FIELDS = new Set([
+    "createdAt",
+    "updatedAt",
+    "status",
+    "uid",
+    "strategyName",
+    "templateName",
+    "activityName",
+  ]);
+
+  public static readonly DEFAULT_STATS_PAGE = 1;
+  public static readonly DEFAULT_STATS_LIMIT = 50;
+  public static readonly MAX_STATS_LIMIT = 200;
+  private static readonly STATS_CACHE_TTL_SECONDS = 300;
 
   /**
    * 创建一条新的消息推送记录
@@ -28,7 +44,10 @@ export class MessageRecordService {
     filters: { [key: string]: any } = {},
     sortField: string = MessageRecordService.DEFAULT_SORT_FIELD,
     sortOrder: "asc" | "desc" = MessageRecordService.DEFAULT_SORT_ORDER
-  ): Promise<{ records: IMessageRecord[]; total: number; page: number; limit: number; totalPages: number }> {
+  ): Promise<{ records: any[]; total: number; page: number; limit: number; totalPages: number; isTotalEstimated: boolean }> {
+    const safePage = Number.isNaN(page) ? MessageRecordService.DEFAULT_PAGE : Math.max(1, page);
+    const safeLimit = Number.isNaN(limit) ? MessageRecordService.DEFAULT_LIMIT : Math.min(MessageRecordService.MAX_LIMIT, Math.max(1, limit));
+
     // 构建查询条件
     const query = this.buildDateFilterQuery(filters);
 
@@ -38,18 +57,28 @@ export class MessageRecordService {
     if (filters.templateName) query.templateName = filters.templateName;
     if (filters.status !== undefined) query.status = filters.status;
 
+    const safeSortField = MessageRecordService.ALLOWED_SORT_FIELDS.has(sortField)
+      ? sortField
+      : MessageRecordService.DEFAULT_SORT_FIELD;
+
     const sortOption: any = {};
-    sortOption[sortField] = sortOrder === "asc" ? 1 : -1;
+    sortOption[safeSortField] = sortOrder === "asc" ? 1 : -1;
+
+    const isUnfilteredQuery = Object.keys(query).length === 0;
+    const total = isUnfilteredQuery
+      ? await MessageRecord.estimatedDocumentCount()
+      : await MessageRecord.countDocuments(query);
+    const isTotalEstimated = isUnfilteredQuery;
 
-    const total = await MessageRecord.countDocuments(query);
     const records = await MessageRecord.find(query)
       .sort(sortOption)
-      .skip((page - 1) * limit)
-      .limit(limit);
+      .skip((safePage - 1) * safeLimit)
+      .limit(safeLimit)
+      .lean();
 
-    const totalPages = Math.ceil(total / limit);
+    const totalPages = Math.ceil(total / safeLimit);
 
-    return { records, total, page, limit, totalPages };
+    return { records, total, page: safePage, limit: safeLimit, totalPages, isTotalEstimated };
   }
 
   /**
@@ -84,60 +113,159 @@ export class MessageRecordService {
    * 获取整体消息推送统计数据(含用户点击率)
    */
   public async getOverallStatistics(startDate?: Date, endDate?: Date, strategyName?: string) {
+    const cacheKey = this.buildStatsCacheKey("overall", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+    });
+
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
+
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    return this.getStatisticsByGroup(matchConditions, []);
+    const result = await this.getStatisticsByGroup(matchConditions, []);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
   }
 
   /**
    * 按活动获取消息统计数据(含用户点击率)
    */
-  public async getStatisticsByActivity(startDate?: Date, endDate?: Date) {
+  public async getStatisticsByActivity(startDate?: Date, endDate?: Date, page: number = MessageRecordService.DEFAULT_STATS_PAGE, limit: number = MessageRecordService.DEFAULT_STATS_LIMIT) {
     const matchConditions = this.buildMatchConditions(startDate, endDate);
     const groupFields = ["activityId", "activityName"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { deliveredRate: -1 });
+    return this.getStatisticsByGroup(matchConditions, groupFields, { deliveredRate: -1 }, page, limit);
   }
 
   /**
    * 按策略获取消息统计数据(含用户点击率)
    */
-  public async getStatisticsByStrategy(startDate?: Date, endDate?: Date, strategyName?: string) {
+  public async getStatisticsByStrategy(
+    startDate?: Date,
+    endDate?: Date,
+    strategyName?: string,
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
+    const cacheKey = this.buildStatsCacheKey("by-strategy", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+      page,
+      limit,
+    });
+
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
+
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
     const groupFields = ["strategyId", "strategyName"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+    const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
   }
 
   /**
    * 按模板获取消息统计数据(含用户点击率)
    */
-  public async getStatisticsByTemplate(startDate?: Date, endDate?: Date, strategyName?: string) {
+  public async getStatisticsByTemplate(
+    startDate?: Date,
+    endDate?: Date,
+    strategyName?: string,
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
     const groupFields = ["templateId", "templateName"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
   }
 
   /**
    * 按国家代码获取消息统计数据(含用户点击率)
    */
-  public async getStatisticsByCc(startDate?: Date, endDate?: Date, strategyName?: string) {
+  public async getStatisticsByCc(
+    startDate?: Date,
+    endDate?: Date,
+    strategyName?: string,
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
     const groupFields = ["cc"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 });
+    return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 }, page, limit);
   }
 
   /**
    * 按图片 URL 获取消息统计数据(含用户点击率)
    */
-  public async getStatisticsByImage(startDate?: Date, endDate?: Date, strategyName?: string) {
+  public async getStatisticsByImage(
+    startDate?: Date,
+    endDate?: Date,
+    strategyName?: string,
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
     const groupFields = ["image"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 });
+    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
   }
 
   /**
    * 按时间维度的趋势分析,每日统计(含用户点击率)
    */
   public async getDailySentTrends(startDate?: Date, endDate?: Date, strategyName?: string) {
-    return this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+    const cacheKey = this.buildStatsCacheKey("daily-trends", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+    });
+
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
+
+    const result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
+  }
+
+  /**
+   * 获取消息统计首屏汇总数据。
+   * 用于首屏减少多次并行重查询。
+   */
+  public async getStatisticsSummary(
+    startDate?: Date,
+    endDate?: Date,
+    strategyName?: string,
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
+    const [overall, dailyTrends, strategyStats] = await Promise.all([
+      this.getOverallStatistics(startDate, endDate, strategyName),
+      this.getDailySentTrends(startDate, endDate, strategyName),
+      this.getStatisticsByStrategy(startDate, endDate, strategyName, page, limit),
+    ]);
+
+    return {
+      overall,
+      dailyTrends,
+      strategyStats,
+      pagination: {
+        page,
+        limit,
+      },
+    };
   }
 
   /**
@@ -229,7 +357,7 @@ export class MessageRecordService {
         $sort: { date: 1, templateName: 1, cc: 1, image: 1 },
       });
 
-      return await MessageRecord.aggregate(pipeline);
+      return await MessageRecord.aggregate(pipeline).allowDiskUse(true);
     } catch (error) {
       console.error("Error fetching multi-dimensional statistics:", error);
       return [];
@@ -396,7 +524,13 @@ export class MessageRecordService {
   /**
    * 按分组获取统计数据的通用方法(支持用户点击率)
    */
-  private async getStatisticsByGroup(matchConditions: any, groupFields: string[] = [], sortOrder: any = { _id: 1 }) {
+  private async getStatisticsByGroup(
+    matchConditions: any,
+    groupFields: string[] = [],
+    sortOrder: any = { _id: 1 },
+    page: number = MessageRecordService.DEFAULT_STATS_PAGE,
+    limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
+  ) {
     try {
       const pipeline: any[] = [];
 
@@ -448,7 +582,17 @@ export class MessageRecordService {
       // 添加排序
       pipeline.push({ $sort: sortOrder });
 
-      const results = await MessageRecord.aggregate(pipeline);
+      if (groupFields.length > 0) {
+        const safePage = Math.max(1, Math.floor(page || MessageRecordService.DEFAULT_STATS_PAGE));
+        const safeLimit = Math.min(
+          MessageRecordService.MAX_STATS_LIMIT,
+          Math.max(1, Math.floor(limit || MessageRecordService.DEFAULT_STATS_LIMIT))
+        );
+        pipeline.push({ $skip: (safePage - 1) * safeLimit });
+        pipeline.push({ $limit: safeLimit });
+      }
+
+      const results = await MessageRecord.aggregate(pipeline).allowDiskUse(true);
       return groupFields.length === 0 ? results[0] : results;
     } catch (error) {
       console.error("Error fetching statistics by group:", error);
@@ -544,10 +688,48 @@ export class MessageRecordService {
         $sort: { date: -1 },
       });
 
-      return await MessageRecord.aggregate(pipeline);
+      return await MessageRecord.aggregate(pipeline).allowDiskUse(true);
     } catch (error) {
       console.error("Error fetching daily trends:", error);
       return [];
     }
   }
+
+  private buildStatsCacheKey(endpoint: string, params: Record<string, unknown>): string {
+    const normalized: Record<string, unknown> = {};
+    Object.keys(params)
+      .sort()
+      .forEach((key) => {
+        normalized[key] = params[key] === undefined ? null : params[key];
+      });
+
+    return `message_stats:${endpoint}:${JSON.stringify(normalized)}`;
+  }
+
+  private async getCache<T>(key: string): Promise<T | null> {
+    try {
+      if (!redisClient.isOpen) {
+        return null;
+      }
+      const cached = await redisClient.get(key);
+      if (!cached) {
+        return null;
+      }
+      return JSON.parse(cached) as T;
+    } catch (error) {
+      console.warn(`[MessageStatsCache] get failed key=${key}`, error);
+      return null;
+    }
+  }
+
+  private async setCache(key: string, value: unknown): Promise<void> {
+    try {
+      if (!redisClient.isOpen) {
+        return;
+      }
+      await redisClient.setEx(key, MessageRecordService.STATS_CACHE_TTL_SECONDS, JSON.stringify(value));
+    } catch (error) {
+      console.warn(`[MessageStatsCache] set failed key=${key}`, error);
+    }
+  }
 }

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

@@ -1,5 +1,5 @@
 import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
-import { CommonModule, DatePipe } from '@angular/common';
+import { CommonModule } from '@angular/common';
 import { HttpClient, HttpParams } from '@angular/common/http';
 import { Router } from '@angular/router';
 import { Observable, forkJoin } from 'rxjs';
@@ -70,7 +70,6 @@ import {
     NzProgressModule,
     NzImageModule,
     NzDescriptionsModule,
-    DatePipe,
     BaseChartDirective,
   ],
   templateUrl: './message-dashboard.component.html',
@@ -339,49 +338,61 @@ export class MessageDashboardComponent implements OnInit {
         'endDate',
         this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : ''
       )
-      .set('strategyName', this.selectedStrategy || '');
+      .set('strategyName', this.selectedStrategy || '')
+      .set('page', '1')
+      .set('limit', '50');
 
-    // 始终加载整体统计和图表数据
+    // 首屏汇总数据:overall + dailyTrends + strategy(默认第一页)
     this.overallLoading = true;
-    this.http
-      .get(`/api/message/statistics/overall`, { params })
-      .pipe(
-        map((res: any) => res?.data || null),
-        catchError((err) => {
-          console.error('Failed to load overall statistics:', err);
-          this.message.error('加载整体统计失败');
-          return of(null);
-        }),
-        finalize(() => (this.overallLoading = false))
-      )
-      .subscribe((data) => {
-        this.overallStats = data;
-      });
-
-    // 加载每日趋势(图表数据)
     this.chartLoading = true;
     this.dailyTrendsLoading = true;
+    this.strategyLoading = this.activeTab === 0;
     this.http
-      .get(`/api/message/statistics/daily-trends`, { params })
+      .get(`/api/message/statistics/summary`, { params })
       .pipe(
-        map((res: any) => res?.data || []),
+        map((res: any) => res?.data || null),
         catchError((err) => {
-          console.error('Failed to load daily trends:', err);
-          this.message.error('加载每日趋势失败');
-          return of([]);
+          console.error('Failed to load summary statistics:', err);
+          this.message.error('加载汇总统计失败');
+          return of(null);
         }),
         finalize(() => {
+          this.overallLoading = false;
           this.chartLoading = false;
           this.dailyTrendsLoading = false;
+          this.strategyLoading = false;
         })
       )
-      .subscribe((data) => {
-        this.dailyTrends = data;
+      .subscribe((summary) => {
+        if (!summary) {
+          this.overallStats = null;
+          this.dailyTrends = [];
+          this.strategyStats = [];
+          this.updateChartData();
+          return;
+        }
+
+        this.overallStats = summary.overall || null;
+        this.dailyTrends = summary.dailyTrends || [];
         this.updateChartData();
+
+        if (this.activeTab === 0) {
+          const strategyData = Array.isArray(summary.strategyStats)
+            ? summary.strategyStats
+            : [];
+          this.strategyStats = strategyData.map((item: any) => ({
+            ...item,
+            expanded: false,
+            dailyData: null,
+            loading: false,
+          }));
+        }
       });
 
-    // 根据当前激活的标签页加载对应数据
-    this.loadActiveTabData(params);
+    // 非策略页签在首次加载时再单独拉取对应数据
+    if (this.activeTab !== 0) {
+      this.loadActiveTabData(params);
+    }
   }
 
   // 加载当前激活标签页的数据

Някои файлове не бяха показани, защото твърде много файлове са промени