Forráskód Böngészése

消息统计预聚合

guoziyun 1 hónapja
szülő
commit
93fae9ed6e

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

@@ -12,6 +12,7 @@ const clients_1 = require("../../src/services/clients"); // 从新的文件导
 const settings = [
     // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
     ["done-rate", "10 0 * * *", require("./done-rate")], // 每天凌晨0点10分, 统计作品完成率
+    ["message-stats-preaggregate", "20 1 * * *", require("./message-stats-preaggregate")], // 每天凌晨1点20分,构建消息统计预聚合(日粒度)
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
     ["monthly-archive-messagerecords", "30 2 1 * *", require("./monthly-archive-messagerecords")], // 每月1日凌晨2:30,归档上月messagerecords到冷表并清理热表
     // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了

+ 90 - 0
oms/dist/services/cron-jobs/message-stats-preaggregate.js

@@ -0,0 +1,90 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+const dayjs_1 = __importDefault(require("dayjs"));
+const messageRecordModel_1 = require("../../src/models/messageRecordModel");
+const messageStatsDailyUidModel_1 = require("../../src/models/messageStatsDailyUidModel");
+const TIMEZONE = "America/Los_Angeles";
+const BATCH_SIZE = 2000;
+async function run(dateStr) {
+    const targetDay = dateStr ? (0, dayjs_1.default)(dateStr).startOf("day") : (0, dayjs_1.default)().subtract(1, "day").startOf("day");
+    if (!targetDay.isValid()) {
+        throw new Error(`Invalid date: ${dateStr}`);
+    }
+    const dateKey = targetDay.format("YYYY-MM-DD");
+    const start = targetDay.toDate();
+    const end = targetDay.add(1, "day").startOf("day").toDate();
+    const startedAt = Date.now();
+    console.log(`[MessageStatsPreAgg Cron] start date=${dateKey}`);
+    const pipeline = [
+        { $match: { createdAt: { $gte: start, $lt: end } } },
+        {
+            $group: {
+                _id: {
+                    date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: TIMEZONE } },
+                    dateKey: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: TIMEZONE } },
+                    strategyId: "$strategyId",
+                    strategyName: "$strategyName",
+                    templateName: "$templateName",
+                    cc: "$cc",
+                    image: "$image",
+                    status: "$status",
+                    inforeground: "$inforeground",
+                    uid: "$uid",
+                },
+                msgCount: { $sum: 1 },
+            },
+        },
+    ];
+    const cursor = messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true).cursor({ batchSize: BATCH_SIZE });
+    let processed = 0;
+    let ops = [];
+    for await (const row of cursor) {
+        const id = row._id || {};
+        ops.push({
+            updateOne: {
+                filter: {
+                    dateKey: id.dateKey,
+                    strategyId: id.strategyId || null,
+                    strategyName: id.strategyName || null,
+                    templateName: id.templateName || null,
+                    cc: id.cc || null,
+                    image: id.image || null,
+                    status: id.status,
+                    inforeground: id.inforeground,
+                    uid: id.uid,
+                },
+                update: {
+                    $set: {
+                        date: id.date,
+                        dateKey: id.dateKey,
+                        strategyId: id.strategyId || null,
+                        strategyName: id.strategyName || null,
+                        templateName: id.templateName || null,
+                        cc: id.cc || null,
+                        image: id.image || null,
+                        status: id.status,
+                        inforeground: id.inforeground,
+                        uid: id.uid,
+                        msgCount: row.msgCount,
+                    },
+                },
+                upsert: true,
+            },
+        });
+        if (ops.length >= BATCH_SIZE) {
+            await messageStatsDailyUidModel_1.MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+            processed += ops.length;
+            ops = [];
+        }
+    }
+    if (ops.length > 0) {
+        await messageStatsDailyUidModel_1.MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+        processed += ops.length;
+    }
+    const summary = `[MessageStatsPreAgg Cron] done date=${dateKey} upsert=${processed} elapsed=${Date.now() - startedAt}ms`;
+    console.log(summary);
+    return summary;
+}
+module.exports = { run };

+ 35 - 0
oms/dist/src/models/messageStatsDailyUidModel.js

@@ -0,0 +1,35 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MessageStatsDailyUid = void 0;
+const mongoose_1 = require("mongoose");
+const messageStatsDailyUidSchema = new mongoose_1.Schema({
+    date: { type: Date, required: true, index: true },
+    dateKey: { type: String, required: true, index: true },
+    strategyId: { type: mongoose_1.Schema.Types.ObjectId, required: false },
+    strategyName: { type: String, required: false, index: true },
+    templateName: { type: String, required: false, index: true },
+    cc: { type: String, required: false, index: true },
+    image: { type: String, required: false, index: true },
+    status: { type: Number, required: true, index: true },
+    inforeground: { type: Boolean, required: false, index: true },
+    uid: { type: String, required: true, index: true },
+    msgCount: { type: Number, required: true, default: 0 },
+}, { timestamps: true });
+// 幂等唯一键:每天、每维度、每状态、每用户一条 rollup 记录
+messageStatsDailyUidSchema.index({
+    dateKey: 1,
+    strategyId: 1,
+    strategyName: 1,
+    templateName: 1,
+    cc: 1,
+    image: 1,
+    status: 1,
+    inforeground: 1,
+    uid: 1,
+}, { unique: true, name: "uniq_daily_uid_rollup" });
+// 常见查询路径索引
+messageStatsDailyUidSchema.index({ dateKey: 1, strategyName: 1 }, { name: "idx_date_strategy" });
+messageStatsDailyUidSchema.index({ dateKey: 1, templateName: 1 }, { name: "idx_date_template" });
+messageStatsDailyUidSchema.index({ dateKey: 1, cc: 1 }, { name: "idx_date_cc" });
+messageStatsDailyUidSchema.index({ dateKey: 1, image: 1 }, { name: "idx_date_image" });
+exports.MessageStatsDailyUid = (0, mongoose_1.model)("MessageStatsDailyUid", messageStatsDailyUidSchema);

+ 123 - 0
oms/dist/src/scripts/backfill-message-stats-preaggregate.js

@@ -0,0 +1,123 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const dayjs_1 = __importDefault(require("dayjs"));
+const mongoose_1 = __importDefault(require("mongoose"));
+const messageRecordModel_1 = require("../models/messageRecordModel");
+const messageStatsDailyUidModel_1 = require("../models/messageStatsDailyUidModel");
+const TIMEZONE = "America/Los_Angeles";
+const BATCH_SIZE = 2000;
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+function parseArg(name) {
+    const idx = process.argv.indexOf(name);
+    if (idx === -1)
+        return undefined;
+    return process.argv[idx + 1];
+}
+async function buildForDay(dateKey) {
+    const start = (0, dayjs_1.default)(dateKey).startOf("day").toDate();
+    const end = (0, dayjs_1.default)(dateKey).add(1, "day").startOf("day").toDate();
+    const pipeline = [
+        {
+            $match: {
+                createdAt: { $gte: start, $lt: end },
+            },
+        },
+        {
+            $group: {
+                _id: {
+                    date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: TIMEZONE } },
+                    dateKey: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: TIMEZONE } },
+                    strategyId: "$strategyId",
+                    strategyName: "$strategyName",
+                    templateName: "$templateName",
+                    cc: "$cc",
+                    image: "$image",
+                    status: "$status",
+                    inforeground: "$inforeground",
+                    uid: "$uid",
+                },
+                msgCount: { $sum: 1 },
+            },
+        },
+    ];
+    const cursor = messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true).cursor({ batchSize: BATCH_SIZE });
+    let processed = 0;
+    let ops = [];
+    for await (const row of cursor) {
+        const id = row._id || {};
+        ops.push({
+            updateOne: {
+                filter: {
+                    dateKey: id.dateKey,
+                    strategyId: id.strategyId || null,
+                    strategyName: id.strategyName || null,
+                    templateName: id.templateName || null,
+                    cc: id.cc || null,
+                    image: id.image || null,
+                    status: id.status,
+                    inforeground: id.inforeground,
+                    uid: id.uid,
+                },
+                update: {
+                    $set: {
+                        date: id.date,
+                        dateKey: id.dateKey,
+                        strategyId: id.strategyId || null,
+                        strategyName: id.strategyName || null,
+                        templateName: id.templateName || null,
+                        cc: id.cc || null,
+                        image: id.image || null,
+                        status: id.status,
+                        inforeground: id.inforeground,
+                        uid: id.uid,
+                        msgCount: row.msgCount,
+                    },
+                },
+                upsert: true,
+            },
+        });
+        if (ops.length >= BATCH_SIZE) {
+            await messageStatsDailyUidModel_1.MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+            processed += ops.length;
+            ops = [];
+        }
+    }
+    if (ops.length > 0) {
+        await messageStatsDailyUidModel_1.MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+        processed += ops.length;
+    }
+    return processed;
+}
+async function main() {
+    const startArg = parseArg("--start");
+    const endArg = parseArg("--end");
+    if (!startArg || !endArg) {
+        throw new Error("Usage: npx ts-node src/scripts/backfill-message-stats-preaggregate.ts --start YYYY-MM-DD --end YYYY-MM-DD");
+    }
+    const start = (0, dayjs_1.default)(startArg).startOf("day");
+    const end = (0, dayjs_1.default)(endArg).startOf("day");
+    if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
+        throw new Error("Invalid date range. Expected --start <= --end, format YYYY-MM-DD");
+    }
+    await mongoose_1.default.connect(MONGO_URI);
+    console.log(`[MessageStatsPreAgg] connected. range=${start.format("YYYY-MM-DD")}..${end.format("YYYY-MM-DD")}`);
+    let cursor = start;
+    let total = 0;
+    while (!cursor.isAfter(end)) {
+        const dateKey = cursor.format("YYYY-MM-DD");
+        const startedAt = Date.now();
+        const processed = await buildForDay(dateKey);
+        total += processed;
+        console.log(`[MessageStatsPreAgg] day=${dateKey} processed=${processed} elapsed=${Date.now() - startedAt}ms`);
+        cursor = cursor.add(1, "day");
+    }
+    console.log(`[MessageStatsPreAgg] done. total upsert ops=${total}`);
+    await mongoose_1.default.disconnect();
+}
+main().catch((error) => {
+    console.error("[MessageStatsPreAgg] fatal", error);
+    process.exit(1);
+});

+ 254 - 9
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 messageStatsDailyUidModel_1 = require("../models/messageStatsDailyUidModel");
 const clients_1 = require("./clients");
 class MessageRecordService {
     logPerf(endpoint, stage, durationMs, extra = {}) {
@@ -21,6 +22,14 @@ class MessageRecordService {
         }
         return 1;
     }
+    formatDateKeyInTimezone(date) {
+        return new Intl.DateTimeFormat("en-CA", {
+            timeZone: MessageRecordService.TIMEZONE,
+            year: "numeric",
+            month: "2-digit",
+            day: "2-digit",
+        }).format(date);
+    }
     /**
      * 创建一条新的消息推送记录
      * @param recordData 消息记录数据
@@ -103,8 +112,22 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
-        const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const result = await this.getStatisticsByGroup(matchConditions, []);
+        let result = null;
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const preAggStartedAt = Date.now();
+            result = await this.getOverallFromPreAgg(startDate, endDate, strategyName);
+            if (result) {
+                this.logPerf("overall", "preagg", Date.now() - preAggStartedAt, {
+                    startDate: startDate.toISOString(),
+                    endDate: endDate.toISOString(),
+                    hasStrategy: Boolean(strategyName),
+                });
+            }
+        }
+        if (!result) {
+            const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+            result = await this.getStatisticsByGroup(matchConditions, []);
+        }
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -133,9 +156,26 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
-        const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const groupFields = ["strategyId", "strategyName"];
-        const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        let result = [];
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const preAggStartedAt = Date.now();
+            result = await this.getByStrategyFromPreAgg(startDate, endDate, strategyName, page, limit);
+            if (Array.isArray(result) && result.length > 0) {
+                this.logPerf("by-strategy", "preagg", Date.now() - preAggStartedAt, {
+                    startDate: startDate.toISOString(),
+                    endDate: endDate.toISOString(),
+                    hasStrategy: Boolean(strategyName),
+                    page,
+                    limit,
+                    resultRows: result.length,
+                });
+            }
+        }
+        if (!Array.isArray(result) || result.length === 0) {
+            const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+            const groupFields = ["strategyId", "strategyName"];
+            result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        }
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -222,7 +262,22 @@ class MessageRecordService {
             console.log(`[MessageStatsCache] hit key=${cacheKey}`);
             return cached;
         }
-        const result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+        let result = [];
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const preAggStartedAt = Date.now();
+            result = await this.getDailyTrendsFromPreAgg(startDate, endDate, strategyName);
+            if (Array.isArray(result) && result.length > 0) {
+                this.logPerf("daily-trends", "preagg", Date.now() - preAggStartedAt, {
+                    startDate: startDate.toISOString(),
+                    endDate: endDate.toISOString(),
+                    hasStrategy: Boolean(strategyName),
+                    resultRows: result.length,
+                });
+            }
+        }
+        if (!Array.isArray(result) || result.length === 0) {
+            result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+        }
         await this.setCache(cacheKey, result);
         console.log(`[MessageStatsCache] miss key=${cacheKey}`);
         return result;
@@ -274,9 +329,34 @@ class MessageRecordService {
                 pagination: { page: safePage, limit: safeLimit },
             };
         }
-        // 任意 cache miss → 单次 $facet 查询替代 3 次独立聚合
-        const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const { overall, strategyStats, dailyTrends } = await this.runSummaryFacetQuery(matchConditions, safePage, safeLimit);
+        let overall = null;
+        let strategyStats = [];
+        let dailyTrends = [];
+        // 任意 cache miss:优先读取预聚合,预聚合不可用时回退实时 facet
+        if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+            const preAggResult = await this.runSummaryFromPreAgg(startDate, endDate, strategyName, safePage, safeLimit);
+            overall = preAggResult.overall;
+            strategyStats = preAggResult.strategyStats;
+            dailyTrends = preAggResult.dailyTrends;
+            this.logPerf("summary", "preagg-query", Date.now() - summaryStartedAt, {
+                overallRows: this.resolveResultRows(overall),
+                strategyRows: this.resolveResultRows(strategyStats),
+                dailyTrendRows: this.resolveResultRows(dailyTrends),
+            });
+        }
+        if (!overall || strategyStats.length === 0 || dailyTrends.length === 0) {
+            const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+            const facetResult = await this.runSummaryFacetQuery(matchConditions, safePage, safeLimit);
+            if (!overall) {
+                overall = facetResult.overall;
+            }
+            if (strategyStats.length === 0) {
+                strategyStats = facetResult.strategyStats;
+            }
+            if (dailyTrends.length === 0) {
+                dailyTrends = facetResult.dailyTrends;
+            }
+        }
         // 回填各子查询缓存(未命中的才写入)
         await Promise.all([
             cachedOverall ? Promise.resolve() : this.setCache(overallCacheKey, overall),
@@ -537,6 +617,170 @@ class MessageRecordService {
             },
         };
     }
+    buildPreAggMatch(startDate, endDate, strategyName) {
+        const startKey = this.formatDateKeyInTimezone(startDate);
+        const endKey = this.formatDateKeyInTimezone(endDate);
+        const match = {
+            dateKey: {
+                $gte: startKey,
+                $lte: endKey,
+            },
+        };
+        if (strategyName) {
+            match.strategyName = strategyName;
+        }
+        return match;
+    }
+    async getOverallFromPreAgg(startDate, endDate, strategyName) {
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: { status: "$status", inforeground: "$inforeground", uid: "$uid" },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: { status: "$_id.status", inforeground: "$_id.inforeground" },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: null,
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields([]) },
+        ];
+        const [result] = await messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+        return result || null;
+    }
+    async getByStrategyFromPreAgg(startDate, endDate, strategyName, page, limit) {
+        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)));
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: {
+                        strategyId: "$strategyId",
+                        strategyName: "$strategyName",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: {
+                        strategyId: "$_id.strategyId",
+                        strategyName: "$_id.strategyName",
+                        status: "$_id.status",
+                        inforeground: "$_id.inforeground",
+                    },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { strategyId: "$_id.strategyId", strategyName: "$_id.strategyName" },
+                    strategyId: { $first: "$_id.strategyId" },
+                    strategyName: { $first: "$_id.strategyName" },
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields(["strategyId", "strategyName"]) },
+            { $sort: { clickThroughRate: -1 } },
+            { $skip: (safePage - 1) * safeLimit },
+            { $limit: safeLimit },
+        ];
+        return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    }
+    async getDailyTrendsFromPreAgg(startDate, endDate, strategyName) {
+        const pipeline = [
+            { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+            {
+                $group: {
+                    _id: {
+                        date: "$date",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    count: { $sum: "$msgCount" },
+                },
+            },
+            {
+                $group: {
+                    _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+                    count: { $sum: "$count" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: "$_id.date",
+                    date: { $first: "$_id.date" },
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            {
+                $project: {
+                    _id: 0,
+                    date: "$date",
+                    totalRecords: "$totalRecords",
+                    sent: "$sent",
+                    delivered: "$delivered",
+                    opened: "$opened",
+                    failed: "$failed",
+                    displayCount: "$displayCount",
+                    displayedUsers: "$displayedUsers",
+                    openedUsers: "$openedUsers",
+                    sentSuccessRate: {
+                        $cond: [{ $eq: ["$totalRecords", 0] }, 0, { $divide: ["$sent", "$totalRecords"] }],
+                    },
+                    deliveredRate: {
+                        $cond: [{ $eq: ["$sent", 0] }, 0, { $divide: ["$delivered", "$sent"] }],
+                    },
+                    displayRate: {
+                        $cond: [{ $eq: ["$delivered", 0] }, 0, { $divide: ["$displayCount", "$delivered"] }],
+                    },
+                    clickThroughRate: {
+                        $cond: [{ $eq: ["$displayCount", 0] }, 0, { $divide: ["$opened", "$displayCount"] }],
+                    },
+                    tokenInvalidationRate: {
+                        $cond: [{ $eq: ["$totalRecords", 0] }, 0, { $divide: ["$failed", "$totalRecords"] }],
+                    },
+                    actualClickThroughRate: {
+                        $cond: [{ $eq: ["$displayedUsers", 0] }, 0, { $divide: ["$openedUsers", "$displayedUsers"] }],
+                    },
+                },
+            },
+            { $sort: { date: -1 } },
+        ];
+        return messageStatsDailyUidModel_1.MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    }
+    async runSummaryFromPreAgg(startDate, endDate, strategyName, page, limit) {
+        const [overall, strategyStats, dailyTrends] = await Promise.all([
+            this.getOverallFromPreAgg(startDate, endDate, strategyName),
+            this.getByStrategyFromPreAgg(startDate, endDate, strategyName, page, limit),
+            this.getDailyTrendsFromPreAgg(startDate, endDate, strategyName),
+        ]);
+        return {
+            overall,
+            strategyStats: strategyStats || [],
+            dailyTrends: dailyTrends || [],
+        };
+    }
     /**
      * summary 首屏接口专用:单次 $facet 查询同时计算 overall / byStrategy / dailyTrends,
      * 替代 3 次独立聚合,减少对同一时间窗口数据的重复扫描。
@@ -928,3 +1172,4 @@ MessageRecordService.DEFAULT_STATS_PAGE = 1;
 MessageRecordService.DEFAULT_STATS_LIMIT = 50;
 MessageRecordService.MAX_STATS_LIMIT = 200;
 MessageRecordService.STATS_CACHE_TTL_SECONDS = 300;
+MessageRecordService.PREAGG_ENABLED = process.env.MESSAGE_STATS_PREAGG_ENABLED === "1";

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

@@ -14,6 +14,7 @@ interface CronJobModule {
 const settings: [string, string, CronJobModule][] = [
   // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
   ["done-rate", "10 0 * * *", require("./done-rate") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
+  ["message-stats-preaggregate", "20 1 * * *", require("./message-stats-preaggregate") as CronJobModule], // 每天凌晨1点20分,构建消息统计预聚合(日粒度)
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
   ["monthly-archive-messagerecords", "30 2 1 * *", require("./monthly-archive-messagerecords") as CronJobModule], // 每月1日凌晨2:30,归档上月messagerecords到冷表并清理热表
   // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了

+ 98 - 0
oms/services/cron-jobs/message-stats-preaggregate.ts

@@ -0,0 +1,98 @@
+import dayjs from "dayjs";
+import { MessageRecord } from "../../src/models/messageRecordModel";
+import { MessageStatsDailyUid } from "../../src/models/messageStatsDailyUidModel";
+
+const TIMEZONE = "America/Los_Angeles";
+const BATCH_SIZE = 2000;
+
+async function run(dateStr?: string | dayjs.Dayjs): Promise<string> {
+  const targetDay = dateStr ? dayjs(dateStr).startOf("day") : dayjs().subtract(1, "day").startOf("day");
+  if (!targetDay.isValid()) {
+    throw new Error(`Invalid date: ${dateStr}`);
+  }
+
+  const dateKey = targetDay.format("YYYY-MM-DD");
+  const start = targetDay.toDate();
+  const end = targetDay.add(1, "day").startOf("day").toDate();
+
+  const startedAt = Date.now();
+  console.log(`[MessageStatsPreAgg Cron] start date=${dateKey}`);
+
+  const pipeline = [
+    { $match: { createdAt: { $gte: start, $lt: end } } },
+    {
+      $group: {
+        _id: {
+          date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: TIMEZONE } },
+          dateKey: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: TIMEZONE } },
+          strategyId: "$strategyId",
+          strategyName: "$strategyName",
+          templateName: "$templateName",
+          cc: "$cc",
+          image: "$image",
+          status: "$status",
+          inforeground: "$inforeground",
+          uid: "$uid",
+        },
+        msgCount: { $sum: 1 },
+      },
+    },
+  ];
+
+  const cursor = MessageRecord.aggregate(pipeline).allowDiskUse(true).cursor({ batchSize: BATCH_SIZE });
+
+  let processed = 0;
+  let ops: any[] = [];
+
+  for await (const row of cursor) {
+    const id = row._id || {};
+    ops.push({
+      updateOne: {
+        filter: {
+          dateKey: id.dateKey,
+          strategyId: id.strategyId || null,
+          strategyName: id.strategyName || null,
+          templateName: id.templateName || null,
+          cc: id.cc || null,
+          image: id.image || null,
+          status: id.status,
+          inforeground: id.inforeground,
+          uid: id.uid,
+        },
+        update: {
+          $set: {
+            date: id.date,
+            dateKey: id.dateKey,
+            strategyId: id.strategyId || null,
+            strategyName: id.strategyName || null,
+            templateName: id.templateName || null,
+            cc: id.cc || null,
+            image: id.image || null,
+            status: id.status,
+            inforeground: id.inforeground,
+            uid: id.uid,
+            msgCount: row.msgCount,
+          },
+        },
+        upsert: true,
+      },
+    });
+
+    if (ops.length >= BATCH_SIZE) {
+      await MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+      processed += ops.length;
+      ops = [];
+    }
+  }
+
+  if (ops.length > 0) {
+    await MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+    processed += ops.length;
+  }
+
+  const summary = `[MessageStatsPreAgg Cron] done date=${dateKey} upsert=${processed} elapsed=${Date.now() - startedAt}ms`;
+  console.log(summary);
+  return summary;
+}
+
+export = { run };

+ 58 - 0
oms/src/models/messageStatsDailyUidModel.ts

@@ -0,0 +1,58 @@
+import { Schema, model, Document } from "mongoose";
+
+export interface IMessageStatsDailyUid extends Document {
+  date: Date;
+  dateKey: string; // YYYY-MM-DD in MessageRecordService.TIMEZONE
+  strategyId?: Schema.Types.ObjectId;
+  strategyName?: string;
+  templateName?: string;
+  cc?: string;
+  image?: string;
+  status: number;
+  inforeground?: boolean;
+  uid: string;
+  msgCount: number;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+const messageStatsDailyUidSchema = new Schema<IMessageStatsDailyUid>(
+  {
+    date: { type: Date, required: true, index: true },
+    dateKey: { type: String, required: true, index: true },
+    strategyId: { type: Schema.Types.ObjectId, required: false },
+    strategyName: { type: String, required: false, index: true },
+    templateName: { type: String, required: false, index: true },
+    cc: { type: String, required: false, index: true },
+    image: { type: String, required: false, index: true },
+    status: { type: Number, required: true, index: true },
+    inforeground: { type: Boolean, required: false, index: true },
+    uid: { type: String, required: true, index: true },
+    msgCount: { type: Number, required: true, default: 0 },
+  },
+  { timestamps: true }
+);
+
+// 幂等唯一键:每天、每维度、每状态、每用户一条 rollup 记录
+messageStatsDailyUidSchema.index(
+  {
+    dateKey: 1,
+    strategyId: 1,
+    strategyName: 1,
+    templateName: 1,
+    cc: 1,
+    image: 1,
+    status: 1,
+    inforeground: 1,
+    uid: 1,
+  },
+  { unique: true, name: "uniq_daily_uid_rollup" }
+);
+
+// 常见查询路径索引
+messageStatsDailyUidSchema.index({ dateKey: 1, strategyName: 1 }, { name: "idx_date_strategy" });
+messageStatsDailyUidSchema.index({ dateKey: 1, templateName: 1 }, { name: "idx_date_template" });
+messageStatsDailyUidSchema.index({ dateKey: 1, cc: 1 }, { name: "idx_date_cc" });
+messageStatsDailyUidSchema.index({ dateKey: 1, image: 1 }, { name: "idx_date_image" });
+
+export const MessageStatsDailyUid = model<IMessageStatsDailyUid>("MessageStatsDailyUid", messageStatsDailyUidSchema);

+ 134 - 0
oms/src/scripts/backfill-message-stats-preaggregate.ts

@@ -0,0 +1,134 @@
+import dayjs from "dayjs";
+import mongoose from "mongoose";
+import { MessageRecord } from "../models/messageRecordModel";
+import { MessageStatsDailyUid } from "../models/messageStatsDailyUidModel";
+
+const TIMEZONE = "America/Los_Angeles";
+const BATCH_SIZE = 2000;
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+
+function parseArg(name: string): string | undefined {
+  const idx = process.argv.indexOf(name);
+  if (idx === -1) return undefined;
+  return process.argv[idx + 1];
+}
+
+async function buildForDay(dateKey: string): Promise<number> {
+  const start = dayjs(dateKey).startOf("day").toDate();
+  const end = dayjs(dateKey).add(1, "day").startOf("day").toDate();
+
+  const pipeline = [
+    {
+      $match: {
+        createdAt: { $gte: start, $lt: end },
+      },
+    },
+    {
+      $group: {
+        _id: {
+          date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: TIMEZONE } },
+          dateKey: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: TIMEZONE } },
+          strategyId: "$strategyId",
+          strategyName: "$strategyName",
+          templateName: "$templateName",
+          cc: "$cc",
+          image: "$image",
+          status: "$status",
+          inforeground: "$inforeground",
+          uid: "$uid",
+        },
+        msgCount: { $sum: 1 },
+      },
+    },
+  ];
+
+  const cursor = MessageRecord.aggregate(pipeline).allowDiskUse(true).cursor({ batchSize: BATCH_SIZE });
+
+  let processed = 0;
+  let ops: any[] = [];
+
+  for await (const row of cursor) {
+    const id = row._id || {};
+    ops.push({
+      updateOne: {
+        filter: {
+          dateKey: id.dateKey,
+          strategyId: id.strategyId || null,
+          strategyName: id.strategyName || null,
+          templateName: id.templateName || null,
+          cc: id.cc || null,
+          image: id.image || null,
+          status: id.status,
+          inforeground: id.inforeground,
+          uid: id.uid,
+        },
+        update: {
+          $set: {
+            date: id.date,
+            dateKey: id.dateKey,
+            strategyId: id.strategyId || null,
+            strategyName: id.strategyName || null,
+            templateName: id.templateName || null,
+            cc: id.cc || null,
+            image: id.image || null,
+            status: id.status,
+            inforeground: id.inforeground,
+            uid: id.uid,
+            msgCount: row.msgCount,
+          },
+        },
+        upsert: true,
+      },
+    });
+
+    if (ops.length >= BATCH_SIZE) {
+      await MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+      processed += ops.length;
+      ops = [];
+    }
+  }
+
+  if (ops.length > 0) {
+    await MessageStatsDailyUid.bulkWrite(ops, { ordered: false });
+    processed += ops.length;
+  }
+
+  return processed;
+}
+
+async function main() {
+  const startArg = parseArg("--start");
+  const endArg = parseArg("--end");
+
+  if (!startArg || !endArg) {
+    throw new Error("Usage: npx ts-node src/scripts/backfill-message-stats-preaggregate.ts --start YYYY-MM-DD --end YYYY-MM-DD");
+  }
+
+  const start = dayjs(startArg).startOf("day");
+  const end = dayjs(endArg).startOf("day");
+  if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
+    throw new Error("Invalid date range. Expected --start <= --end, format YYYY-MM-DD");
+  }
+
+  await mongoose.connect(MONGO_URI);
+  console.log(`[MessageStatsPreAgg] connected. range=${start.format("YYYY-MM-DD")}..${end.format("YYYY-MM-DD")}`);
+
+  let cursor = start;
+  let total = 0;
+  while (!cursor.isAfter(end)) {
+    const dateKey = cursor.format("YYYY-MM-DD");
+    const startedAt = Date.now();
+    const processed = await buildForDay(dateKey);
+    total += processed;
+    console.log(`[MessageStatsPreAgg] day=${dateKey} processed=${processed} elapsed=${Date.now() - startedAt}ms`);
+    cursor = cursor.add(1, "day");
+  }
+
+  console.log(`[MessageStatsPreAgg] done. total upsert ops=${total}`);
+  await mongoose.disconnect();
+}
+
+main().catch((error) => {
+  console.error("[MessageStatsPreAgg] fatal", error);
+  process.exit(1);
+});

+ 282 - 13
oms/src/services/messageRecordService.ts

@@ -1,4 +1,5 @@
 import { MessageRecord, IMessageRecord } from "../models/messageRecordModel";
+import { MessageStatsDailyUid } from "../models/messageStatsDailyUidModel";
 import { redisClient } from "./clients";
 
 export class MessageRecordService {
@@ -24,6 +25,7 @@ export class MessageRecordService {
   public static readonly DEFAULT_STATS_LIMIT = 50;
   public static readonly MAX_STATS_LIMIT = 200;
   private static readonly STATS_CACHE_TTL_SECONDS = 300;
+  private static readonly PREAGG_ENABLED = process.env.MESSAGE_STATS_PREAGG_ENABLED === "1";
 
   private logPerf(endpoint: string, stage: string, durationMs: number, extra: Record<string, unknown> = {}) {
     console.log(
@@ -47,6 +49,15 @@ export class MessageRecordService {
     return 1;
   }
 
+  private formatDateKeyInTimezone(date: Date): string {
+    return new Intl.DateTimeFormat("en-CA", {
+      timeZone: MessageRecordService.TIMEZONE,
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+    }).format(date);
+  }
+
   /**
    * 创建一条新的消息推送记录
    * @param recordData 消息记录数据
@@ -147,8 +158,24 @@ export class MessageRecordService {
       return cached;
     }
 
-    const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const result = await this.getStatisticsByGroup(matchConditions, []);
+    let result: any = null;
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const preAggStartedAt = Date.now();
+      result = await this.getOverallFromPreAgg(startDate, endDate, strategyName);
+      if (result) {
+        this.logPerf("overall", "preagg", Date.now() - preAggStartedAt, {
+          startDate: startDate.toISOString(),
+          endDate: endDate.toISOString(),
+          hasStrategy: Boolean(strategyName),
+        });
+      }
+    }
+
+    if (!result) {
+      const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+      result = await this.getStatisticsByGroup(matchConditions, []);
+    }
+
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -187,9 +214,28 @@ export class MessageRecordService {
       return cached;
     }
 
-    const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const groupFields = ["strategyId", "strategyName"];
-    const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    let result: any[] = [];
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const preAggStartedAt = Date.now();
+      result = await this.getByStrategyFromPreAgg(startDate, endDate, strategyName, page, limit);
+      if (Array.isArray(result) && result.length > 0) {
+        this.logPerf("by-strategy", "preagg", Date.now() - preAggStartedAt, {
+          startDate: startDate.toISOString(),
+          endDate: endDate.toISOString(),
+          hasStrategy: Boolean(strategyName),
+          page,
+          limit,
+          resultRows: result.length,
+        });
+      }
+    }
+
+    if (!Array.isArray(result) || result.length === 0) {
+      const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+      const groupFields = ["strategyId", "strategyName"];
+      result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    }
+
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -300,7 +346,24 @@ export class MessageRecordService {
       return cached;
     }
 
-    const result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+    let result: any[] = [];
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const preAggStartedAt = Date.now();
+      result = await this.getDailyTrendsFromPreAgg(startDate, endDate, strategyName);
+      if (Array.isArray(result) && result.length > 0) {
+        this.logPerf("daily-trends", "preagg", Date.now() - preAggStartedAt, {
+          startDate: startDate.toISOString(),
+          endDate: endDate.toISOString(),
+          hasStrategy: Boolean(strategyName),
+          resultRows: result.length,
+        });
+      }
+    }
+
+    if (!Array.isArray(result) || result.length === 0) {
+      result = await this.getDailyTrendsByDimensions(startDate, endDate, strategyName, []);
+    }
+
     await this.setCache(cacheKey, result);
     console.log(`[MessageStatsCache] miss key=${cacheKey}`);
     return result;
@@ -367,13 +430,36 @@ export class MessageRecordService {
       };
     }
 
-    // 任意 cache miss → 单次 $facet 查询替代 3 次独立聚合
-    const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const { overall, strategyStats, dailyTrends } = await this.runSummaryFacetQuery(
-      matchConditions,
-      safePage,
-      safeLimit
-    );
+    let overall: any = null;
+    let strategyStats: any[] = [];
+    let dailyTrends: any[] = [];
+
+    // 任意 cache miss:优先读取预聚合,预聚合不可用时回退实时 facet
+    if (MessageRecordService.PREAGG_ENABLED && startDate && endDate) {
+      const preAggResult = await this.runSummaryFromPreAgg(startDate, endDate, strategyName, safePage, safeLimit);
+      overall = preAggResult.overall;
+      strategyStats = preAggResult.strategyStats;
+      dailyTrends = preAggResult.dailyTrends;
+      this.logPerf("summary", "preagg-query", Date.now() - summaryStartedAt, {
+        overallRows: this.resolveResultRows(overall),
+        strategyRows: this.resolveResultRows(strategyStats),
+        dailyTrendRows: this.resolveResultRows(dailyTrends),
+      });
+    }
+
+    if (!overall || strategyStats.length === 0 || dailyTrends.length === 0) {
+      const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
+      const facetResult = await this.runSummaryFacetQuery(matchConditions, safePage, safeLimit);
+      if (!overall) {
+        overall = facetResult.overall;
+      }
+      if (strategyStats.length === 0) {
+        strategyStats = facetResult.strategyStats;
+      }
+      if (dailyTrends.length === 0) {
+        dailyTrends = facetResult.dailyTrends;
+      }
+    }
 
     // 回填各子查询缓存(未命中的才写入)
     await Promise.all([
@@ -667,6 +753,189 @@ export class MessageRecordService {
     };
   }
 
+  private buildPreAggMatch(startDate: Date, endDate: Date, strategyName?: string): any {
+    const startKey = this.formatDateKeyInTimezone(startDate);
+    const endKey = this.formatDateKeyInTimezone(endDate);
+    const match: any = {
+      dateKey: {
+        $gte: startKey,
+        $lte: endKey,
+      },
+    };
+    if (strategyName) {
+      match.strategyName = strategyName;
+    }
+    return match;
+  }
+
+  private async getOverallFromPreAgg(startDate: Date, endDate: Date, strategyName?: string) {
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: { status: "$status", inforeground: "$inforeground", uid: "$uid" },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: { status: "$_id.status", inforeground: "$_id.inforeground" },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: null,
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields([]) },
+    ];
+
+    const [result] = await MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+    return result || null;
+  }
+
+  private async getByStrategyFromPreAgg(startDate: Date, endDate: Date, strategyName: string | undefined, page: number, limit: number) {
+    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))
+    );
+
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: {
+            strategyId: "$strategyId",
+            strategyName: "$strategyName",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: {
+            strategyId: "$_id.strategyId",
+            strategyName: "$_id.strategyName",
+            status: "$_id.status",
+            inforeground: "$_id.inforeground",
+          },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { strategyId: "$_id.strategyId", strategyName: "$_id.strategyName" },
+          strategyId: { $first: "$_id.strategyId" },
+          strategyName: { $first: "$_id.strategyName" },
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields(["strategyId", "strategyName"]) },
+      { $sort: { clickThroughRate: -1 } },
+      { $skip: (safePage - 1) * safeLimit },
+      { $limit: safeLimit },
+    ];
+
+    return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+  }
+
+  private async getDailyTrendsFromPreAgg(startDate: Date, endDate: Date, strategyName?: string) {
+    const pipeline: any[] = [
+      { $match: this.buildPreAggMatch(startDate, endDate, strategyName) },
+      {
+        $group: {
+          _id: {
+            date: "$date",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          count: { $sum: "$msgCount" },
+        },
+      },
+      {
+        $group: {
+          _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+          count: { $sum: "$count" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: "$_id.date",
+          date: { $first: "$_id.date" },
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      {
+        $project: {
+          _id: 0,
+          date: "$date",
+          totalRecords: "$totalRecords",
+          sent: "$sent",
+          delivered: "$delivered",
+          opened: "$opened",
+          failed: "$failed",
+          displayCount: "$displayCount",
+          displayedUsers: "$displayedUsers",
+          openedUsers: "$openedUsers",
+          sentSuccessRate: {
+            $cond: [{ $eq: ["$totalRecords", 0] }, 0, { $divide: ["$sent", "$totalRecords"] }],
+          },
+          deliveredRate: {
+            $cond: [{ $eq: ["$sent", 0] }, 0, { $divide: ["$delivered", "$sent"] }],
+          },
+          displayRate: {
+            $cond: [{ $eq: ["$delivered", 0] }, 0, { $divide: ["$displayCount", "$delivered"] }],
+          },
+          clickThroughRate: {
+            $cond: [{ $eq: ["$displayCount", 0] }, 0, { $divide: ["$opened", "$displayCount"] }],
+          },
+          tokenInvalidationRate: {
+            $cond: [{ $eq: ["$totalRecords", 0] }, 0, { $divide: ["$failed", "$totalRecords"] }],
+          },
+          actualClickThroughRate: {
+            $cond: [{ $eq: ["$displayedUsers", 0] }, 0, { $divide: ["$openedUsers", "$displayedUsers"] }],
+          },
+        },
+      },
+      { $sort: { date: -1 } },
+    ];
+
+    return MessageStatsDailyUid.aggregate(pipeline).allowDiskUse(true);
+  }
+
+  private async runSummaryFromPreAgg(
+    startDate: Date,
+    endDate: Date,
+    strategyName: string | undefined,
+    page: number,
+    limit: number
+  ): Promise<{ overall: any; strategyStats: any[]; dailyTrends: any[] }> {
+    const [overall, strategyStats, dailyTrends] = await Promise.all([
+      this.getOverallFromPreAgg(startDate, endDate, strategyName),
+      this.getByStrategyFromPreAgg(startDate, endDate, strategyName, page, limit),
+      this.getDailyTrendsFromPreAgg(startDate, endDate, strategyName),
+    ]);
+
+    return {
+      overall,
+      strategyStats: strategyStats || [],
+      dailyTrends: dailyTrends || [],
+    };
+  }
+
   /**
    * summary 首屏接口专用:单次 $facet 查询同时计算 overall / byStrategy / dailyTrends,
    * 替代 3 次独立聚合,减少对同一时间窗口数据的重复扫描。