Explorar el Código

messagerecord聚合查询优化;增加messagerecord冷热数据分表脚本

guoziyun hace 1 mes
padre
commit
5ea6870d57

+ 155 - 0
oms/dist/src/scripts/archive-messagerecords.js

@@ -0,0 +1,155 @@
+"use strict";
+/**
+ * archive-messagerecords.ts
+ *
+ * 将 messagerecords 热表中超过 HOT_DAYS 天的数据按月归档到
+ * messagerecords_archive_YYYYMM 集合。
+ *
+ * 用法:
+ *   npx ts-node src/scripts/archive-messagerecords.ts            # dry-run(仅统计,不写库)
+ *   npx ts-node src/scripts/archive-messagerecords.ts --execute  # 真正执行归档
+ *   npx ts-node src/scripts/archive-messagerecords.ts --execute --month 2026-02  # 仅处理指定月份
+ *
+ * 归档规则:
+ *   - 归档截止日:今天往前 HOT_DAYS 天(默认 180)的完整月末
+ *   - 幂等:重跑不重复插,使用 replaceOne+upsert by _id
+ *   - 每批 BATCH_SIZE 条,防止 OOM
+ */
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const mongoose_1 = __importDefault(require("mongoose"));
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const HOT_DAYS = 180;
+const BATCH_SIZE = 500;
+// ---------- CLI 参数 ----------
+const isDryRun = !process.argv.includes("--execute");
+const monthArg = (() => {
+    const idx = process.argv.indexOf("--month");
+    return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+// ---------- 工具 ----------
+function monthStr(year, month) {
+    return `${year}${String(month).padStart(2, "0")}`;
+}
+/** 计算需要归档的月份列表(完整月,不包含当前热数据月) */
+function calcArchiveMonths(hotDays) {
+    const now = new Date();
+    // 归档截止:当前日期 - hotDays,取当月月初为边界(保守归档完整月)
+    const cutoff = new Date(now);
+    cutoff.setDate(cutoff.getDate() - hotDays);
+    // 归档到 cutoff 所在月份的上一个月末(避免截断当月数据)
+    const cutoffMonth = new Date(cutoff.getFullYear(), cutoff.getMonth(), 1);
+    const months = [];
+    // 找出 messagerecords 中最早记录的月份上限:不超过 cutoffMonth
+    // 实际起点由 findEarliestMonth 确定,这里先返回最大范围
+    // 从 2024-01 到 cutoffMonth(exclusive)
+    const start = new Date(2024, 5, 1); // 2024-06 数据起始月
+    const cursor = new Date(start);
+    while (cursor < cutoffMonth) {
+        const y = cursor.getFullYear();
+        const m = cursor.getMonth(); // 0-indexed
+        const monthStart = new Date(y, m, 1);
+        const monthEnd = new Date(y, m + 1, 1);
+        const yyyymm = monthStr(y, m + 1);
+        months.push({ yyyymm, start: monthStart, end: monthEnd });
+        cursor.setMonth(cursor.getMonth() + 1);
+    }
+    return months;
+}
+/** 获取归档集合(动态 model,复用 connection) */
+function getArchiveCollection(conn, yyyymm) {
+    const collectionName = `messagerecords_archive_${yyyymm}`;
+    // 直接操作 native collection,不走 Mongoose model,保持 schema 灵活
+    return conn.collection(collectionName);
+}
+// ---------- 主流程 ----------
+async function run() {
+    console.log(`[Archive] mode=${isDryRun ? "DRY-RUN" : "EXECUTE"} hotDays=${HOT_DAYS} batchSize=${BATCH_SIZE}`);
+    if (monthArg) {
+        console.log(`[Archive] scope=single month=${monthArg}`);
+    }
+    const conn = await mongoose_1.default.createConnection(MONGO_URI).asPromise();
+    console.log("[Archive] Connected to MongoDB.");
+    const hotCol = conn.collection("messagerecords");
+    // 确定要处理的月份列表
+    let months = calcArchiveMonths(HOT_DAYS);
+    if (monthArg) {
+        const [y, m] = monthArg.split("-").map(Number);
+        months = months.filter((mo) => mo.yyyymm === monthStr(y, m));
+        if (months.length === 0) {
+            console.error(`[Archive] ERROR: month ${monthArg} is not in archivable range (must be >= ${HOT_DAYS} days ago).`);
+            await conn.close();
+            process.exit(1);
+        }
+    }
+    let totalArchived = 0;
+    let totalSkipped = 0;
+    for (const { yyyymm, start, end } of months) {
+        const hotCount = await hotCol.countDocuments({ createdAt: { $gte: start, $lt: end } });
+        if (hotCount === 0) {
+            console.log(`[Archive] ${yyyymm}: hotCount=0, skip`);
+            continue;
+        }
+        const archiveCol = getArchiveCollection(conn, yyyymm);
+        const archiveCount = await archiveCol.countDocuments({ createdAt: { $gte: start, $lt: end } });
+        if (archiveCount >= hotCount) {
+            console.log(`[Archive] ${yyyymm}: already complete (hot=${hotCount}, archive=${archiveCount}), skip`);
+            totalSkipped += hotCount;
+            continue;
+        }
+        console.log(`[Archive] ${yyyymm}: hot=${hotCount} archive=${archiveCount} pending=${hotCount - archiveCount}`);
+        if (isDryRun) {
+            totalArchived += hotCount - archiveCount;
+            continue;
+        }
+        // 确保归档集合有 createdAt 索引(与热表一致)
+        await archiveCol.createIndex({ createdAt: 1 }, { background: true });
+        await archiveCol.createIndex({ uid: 1 }, { background: true });
+        await archiveCol.createIndex({ strategyName: 1, createdAt: 1 }, { background: true });
+        // 游标分批迁移
+        let batchInserted = 0;
+        const cursor = hotCol.find({ createdAt: { $gte: start, $lt: end } }).batchSize(BATCH_SIZE);
+        let batch = [];
+        for await (const doc of cursor) {
+            batch.push(doc);
+            if (batch.length >= BATCH_SIZE) {
+                const ops = batch.map((d) => ({
+                    replaceOne: {
+                        filter: { _id: d._id },
+                        replacement: d,
+                        upsert: true,
+                    },
+                }));
+                const result = await archiveCol.bulkWrite(ops, { ordered: false });
+                batchInserted += result.upsertedCount + result.modifiedCount;
+                batch = [];
+                process.stdout.write(`\r[Archive] ${yyyymm}: archived ${batchInserted}/${hotCount - archiveCount} ...`);
+            }
+        }
+        // 处理最后一批
+        if (batch.length > 0) {
+            const ops = batch.map((d) => ({
+                replaceOne: {
+                    filter: { _id: d._id },
+                    replacement: d,
+                    upsert: true,
+                },
+            }));
+            const result = await archiveCol.bulkWrite(ops, { ordered: false });
+            batchInserted += result.upsertedCount + result.modifiedCount;
+        }
+        console.log(`\n[Archive] ${yyyymm}: done inserted/updated=${batchInserted}`);
+        totalArchived += batchInserted;
+    }
+    console.log(`\n[Archive] SUMMARY mode=${isDryRun ? "DRY-RUN" : "EXECUTE"} totalArchived=${totalArchived} totalSkipped=${totalSkipped}`);
+    if (isDryRun) {
+        console.log("[Archive] DRY-RUN: no data was written. Re-run with --execute to apply.");
+    }
+    await conn.close();
+}
+run().catch((err) => {
+    console.error("[Archive] FATAL:", err);
+    process.exit(1);
+});

+ 167 - 0
oms/dist/src/scripts/purge-messagerecords-hot.js

@@ -0,0 +1,167 @@
+"use strict";
+/**
+ * purge-messagerecords-hot.ts
+ *
+ * 从 messagerecords 热表删除已完整归档的历史数据。
+ *
+ * 安全要求(双重确认):
+ *   --execute    声明要真正执行
+ *   --confirmed  在执行前打印操作计划后,再次用此参数确认
+ *
+ * 用法:
+ *   # Step 1: 查看将要删除的月份和估算行数(dry-run,不删除)
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts
+ *
+ *   # Step 2: 确认 verify 全部通过后,执行删除
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts --execute --confirmed
+ *
+ *   # 仅删除指定月份
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts --execute --confirmed --month 2024-06
+ *
+ * 内置保护:
+ *   - 热表中仍有数据但无对应归档集合的月份,拒绝删除
+ *   - 归档数 < 热表数的月份,拒绝删除(提示先运行 archive)
+ *   - 近 HOT_DAYS 天的数据(热数据),即使有归档集合也拒绝删除
+ */
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const mongoose_1 = __importDefault(require("mongoose"));
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const HOT_DAYS = 180;
+const DELETE_BATCH_SIZE = 1000; // deleteMany 单次处理的上限,用时间窗切分
+const isExecute = process.argv.includes("--execute");
+const isConfirmed = process.argv.includes("--confirmed");
+const monthArg = (() => {
+    const idx = process.argv.indexOf("--month");
+    return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+// ---------- 工具 ----------
+function parseArchiveMonthFromName(name) {
+    const m = name.match(/^messagerecords_archive_(\d{6})$/);
+    return m ? m[1] : null;
+}
+function yyyymmToRange(yyyymm) {
+    const year = parseInt(yyyymm.slice(0, 4), 10);
+    const month = parseInt(yyyymm.slice(4, 6), 10) - 1; // 0-indexed
+    return {
+        start: new Date(year, month, 1),
+        end: new Date(year, month + 1, 1),
+    };
+}
+function calcHotCutoffStart() {
+    const d = new Date();
+    d.setDate(d.getDate() - HOT_DAYS);
+    // 保守取当月月初,整月视为热数据
+    return new Date(d.getFullYear(), d.getMonth(), 1);
+}
+// ---------- 主流程 ----------
+async function run() {
+    const dryRun = !(isExecute && isConfirmed);
+    console.log(`[Purge] mode=${dryRun ? "DRY-RUN" : "EXECUTE"} hotDays=${HOT_DAYS}`);
+    if (isExecute && !isConfirmed) {
+        console.warn("[Purge] WARNING: --execute requires --confirmed as a second confirmation. Running in DRY-RUN mode.");
+    }
+    const conn = await mongoose_1.default.createConnection(MONGO_URI).asPromise();
+    console.log("[Purge] Connected to MongoDB.");
+    const hotCol = conn.collection("messagerecords");
+    const hotCutoff = calcHotCutoffStart();
+    // 获取所有 archive 集合
+    const allCollections = await conn.db.listCollections().toArray();
+    const archiveMap = new Map(); // yyyymm -> collectionName
+    for (const c of allCollections) {
+        const yyyymm = parseArchiveMonthFromName(c.name);
+        if (yyyymm)
+            archiveMap.set(yyyymm, c.name);
+    }
+    // 找出热表中所有有数据、且早于 hotCutoff 的月份
+    // 用聚合按月分组统计
+    const pipeline = [
+        { $match: { createdAt: { $lt: hotCutoff } } },
+        {
+            $group: {
+                _id: { $dateToString: { format: "%Y%m", date: "$createdAt" } },
+                count: { $sum: 1 },
+            },
+        },
+        { $sort: { _id: 1 } },
+    ];
+    console.log("[Purge] Scanning hot table for purgeable months (this may take a moment)...");
+    const hotMonths = [];
+    const cursor = hotCol.aggregate(pipeline, { allowDiskUse: true, maxTimeMS: 120000 });
+    for await (const row of cursor) {
+        hotMonths.push({ yyyymm: row._id, hotCount: row.count });
+    }
+    if (hotMonths.length === 0) {
+        console.log("[Purge] No purgeable data found in hot table (all within hot window or already purged).");
+        await conn.close();
+        process.exit(0);
+    }
+    // 过滤单月
+    let targetMonths = hotMonths;
+    if (monthArg) {
+        const yyyymm = monthArg.replace("-", "");
+        targetMonths = hotMonths.filter((m) => m.yyyymm === yyyymm);
+        if (targetMonths.length === 0) {
+            console.error(`[Purge] ERROR: month ${monthArg} has no purgeable data in hot table.`);
+            await conn.close();
+            process.exit(1);
+        }
+    }
+    // 预检:每个目标月份都必须有完整归档
+    console.log(`\n[Purge] Pre-check: verifying archive completeness for ${targetMonths.length} month(s)...`);
+    const blockers = [];
+    const plan = [];
+    for (const { yyyymm, hotCount } of targetMonths) {
+        const { start, end } = yyyymmToRange(yyyymm);
+        if (!archiveMap.has(yyyymm)) {
+            console.log(`[Purge] ${yyyymm}: NO ARCHIVE COLLECTION → BLOCKED`);
+            blockers.push(yyyymm);
+            continue;
+        }
+        const archiveCount = await conn.collection(archiveMap.get(yyyymm)).countDocuments({ createdAt: { $gte: start, $lt: end } });
+        if (archiveCount < hotCount) {
+            console.log(`[Purge] ${yyyymm}: INCOMPLETE (hot=${hotCount} archive=${archiveCount}) → BLOCKED`);
+            blockers.push(yyyymm);
+        }
+        else {
+            console.log(`[Purge] ${yyyymm}: OK (hot=${hotCount.toLocaleString()} archive=${archiveCount.toLocaleString()})`);
+            plan.push({ yyyymm, hotCount, archiveCount });
+        }
+    }
+    if (blockers.length > 0) {
+        console.error(`\n[Purge] ABORT: ${blockers.length} month(s) blocked (${blockers.join(", ")}). Run archive-messagerecords.ts --execute first.`);
+        await conn.close();
+        process.exit(1);
+    }
+    // 打印执行计划
+    const totalRows = plan.reduce((s, p) => s + p.hotCount, 0);
+    console.log(`\n[Purge] PLAN: will delete ${totalRows.toLocaleString()} rows from messagerecords across ${plan.length} month(s):`);
+    for (const { yyyymm, hotCount } of plan) {
+        console.log(`  ${yyyymm}: ${hotCount.toLocaleString()} rows`);
+    }
+    if (dryRun) {
+        console.log("\n[Purge] DRY-RUN: no data deleted. Re-run with --execute --confirmed to apply.");
+        await conn.close();
+        process.exit(0);
+    }
+    // 执行删除
+    console.log("\n[Purge] Starting deletion...");
+    let totalDeleted = 0;
+    for (const { yyyymm, hotCount } of plan) {
+        const { start, end } = yyyymmToRange(yyyymm);
+        const t0 = Date.now();
+        const result = await hotCol.deleteMany({ createdAt: { $gte: start, $lt: end } });
+        const elapsed = Date.now() - t0;
+        console.log(`[Purge] ${yyyymm}: deleted=${result.deletedCount.toLocaleString()} (expected≈${hotCount.toLocaleString()}) elapsed=${elapsed}ms`);
+        totalDeleted += result.deletedCount;
+    }
+    console.log(`\n[Purge] DONE: total deleted=${totalDeleted.toLocaleString()} rows from messagerecords hot table.`);
+    console.log("[Purge] Recommend running verify-messagerecords-archive.ts again to confirm integrity.");
+    await conn.close();
+}
+run().catch((err) => {
+    console.error("[Purge] FATAL:", err);
+    process.exit(1);
+});

+ 102 - 0
oms/dist/src/scripts/verify-messagerecords-archive.js

@@ -0,0 +1,102 @@
+"use strict";
+/**
+ * verify-messagerecords-archive.ts
+ *
+ * 校验 messagerecords_archive_YYYYMM 归档集合与热表的数据完整性。
+ * 对每个已存在的归档集合,按月比较热表与归档的记录数。
+ *
+ * 用法:
+ *   npx ts-node src/scripts/verify-messagerecords-archive.ts             # 检查所有归档月份
+ *   npx ts-node src/scripts/verify-messagerecords-archive.ts --month 2024-06  # 仅检查指定月份
+ *
+ * 输出:
+ *   每月打印 hotCount / archiveCount 以及 PASS / FAIL
+ *   最终汇总 PASS 月数 / FAIL 月数
+ *   如有 FAIL,退出码为 1(供脚本链式调用判断)
+ */
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const mongoose_1 = __importDefault(require("mongoose"));
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const monthArg = (() => {
+    const idx = process.argv.indexOf("--month");
+    return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+function parseArchiveMonthFromName(name) {
+    const m = name.match(/^messagerecords_archive_(\d{6})$/);
+    return m ? m[1] : null;
+}
+function yyyymmToRange(yyyymm) {
+    const year = parseInt(yyyymm.slice(0, 4), 10);
+    const month = parseInt(yyyymm.slice(4, 6), 10) - 1; // 0-indexed
+    return {
+        start: new Date(year, month, 1),
+        end: new Date(year, month + 1, 1),
+    };
+}
+async function run() {
+    console.log("[Verify] Connecting to MongoDB...");
+    const conn = await mongoose_1.default.createConnection(MONGO_URI).asPromise();
+    console.log("[Verify] Connected.");
+    const hotCol = conn.collection("messagerecords");
+    // 获取所有 archive 集合
+    const allCollections = await conn.db.listCollections().toArray();
+    let archiveCollections = allCollections
+        .map((c) => c.name)
+        .filter((name) => parseArchiveMonthFromName(name) !== null)
+        .sort();
+    if (archiveCollections.length === 0) {
+        console.log("[Verify] No archive collections found. Run archive-messagerecords.ts --execute first.");
+        await conn.close();
+        process.exit(0);
+    }
+    // 过滤单月
+    if (monthArg) {
+        const yyyymm = monthArg.replace("-", "");
+        archiveCollections = archiveCollections.filter((name) => name.endsWith(yyyymm));
+        if (archiveCollections.length === 0) {
+            console.error(`[Verify] ERROR: No archive collection found for month ${monthArg}`);
+            await conn.close();
+            process.exit(1);
+        }
+    }
+    console.log(`[Verify] Checking ${archiveCollections.length} archive collection(s)...\n`);
+    let passCount = 0;
+    let failCount = 0;
+    const failMonths = [];
+    for (const colName of archiveCollections) {
+        const yyyymm = parseArchiveMonthFromName(colName);
+        const { start, end } = yyyymmToRange(yyyymm);
+        const [hotCount, archiveCount] = await Promise.all([
+            hotCol.countDocuments({ createdAt: { $gte: start, $lt: end } }),
+            conn.collection(colName).countDocuments({ createdAt: { $gte: start, $lt: end } }),
+        ]);
+        const status = archiveCount >= hotCount ? "PASS" : "FAIL";
+        const diff = archiveCount - hotCount;
+        const diffStr = diff >= 0 ? `+${diff}` : `${diff}`;
+        console.log(`[Verify] ${yyyymm}: hot=${hotCount.toLocaleString().padStart(10)} archive=${archiveCount.toLocaleString().padStart(10)} diff=${diffStr.padStart(7)} → ${status}`);
+        if (status === "PASS") {
+            passCount++;
+        }
+        else {
+            failCount++;
+            failMonths.push(yyyymm);
+        }
+    }
+    console.log(`\n[Verify] SUMMARY: PASS=${passCount} FAIL=${failCount}`);
+    if (failMonths.length > 0) {
+        console.log(`[Verify] FAILED months: ${failMonths.join(", ")}`);
+        console.log("[Verify] Re-run archive-messagerecords.ts --execute for failed months before purging.");
+    }
+    else {
+        console.log("[Verify] All archive collections are complete. Safe to run purge-messagerecords-hot.ts.");
+    }
+    await conn.close();
+    process.exit(failCount > 0 ? 1 : 0);
+}
+run().catch((err) => {
+    console.error("[Verify] FATAL:", err);
+    process.exit(1);
+});

+ 222 - 48
oms/dist/src/services/messageRecordService.js

@@ -189,46 +189,73 @@ class MessageRecordService {
      */
     async getStatisticsSummary(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
         const summaryStartedAt = Date.now();
-        const [overall, dailyTrends, strategyStats] = await Promise.all([
-            (async () => {
-                const startedAt = Date.now();
-                const result = await this.getOverallStatistics(startDate, endDate, strategyName);
-                this.logPerf("summary", "overall", Date.now() - startedAt, {
-                    resultRows: this.resolveResultRows(result),
-                });
-                return result;
-            })(),
-            (async () => {
-                const startedAt = Date.now();
-                const result = await this.getDailySentTrends(startDate, endDate, strategyName);
-                this.logPerf("summary", "daily-trends", Date.now() - startedAt, {
-                    resultRows: this.resolveResultRows(result),
-                });
-                return result;
-            })(),
-            (async () => {
-                const startedAt = Date.now();
-                const result = await this.getStatisticsByStrategy(startDate, endDate, strategyName, page, limit);
-                this.logPerf("summary", "by-strategy", Date.now() - startedAt, {
-                    resultRows: this.resolveResultRows(result),
-                    page,
-                    limit,
-                });
-                return result;
-            })(),
+        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)));
+        // 预先构建 3 个子查询的 cache key,与各自方法保持完全一致
+        const overallCacheKey = this.buildStatsCacheKey("overall", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+        });
+        const dailyTrendsCacheKey = this.buildStatsCacheKey("daily-trends", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+        });
+        const strategyCacheKey = this.buildStatsCacheKey("by-strategy", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+            page: safePage,
+            limit: safeLimit,
+        });
+        // 并行检查全部缓存
+        const [cachedOverall, cachedDailyTrends, cachedStrategy] = await Promise.all([
+            this.getCache(overallCacheKey),
+            this.getCache(dailyTrendsCacheKey),
+            this.getCache(strategyCacheKey),
+        ]);
+        if (cachedOverall && cachedDailyTrends && cachedStrategy) {
+            // 全部命中,直接返回
+            console.log(`[MessageStatsCache] hit key=summary:all-3`);
+            this.logPerf("summary", "total", Date.now() - summaryStartedAt, {
+                cacheHit: true,
+                dailyTrendRows: this.resolveResultRows(cachedDailyTrends),
+                strategyRows: this.resolveResultRows(cachedStrategy),
+            });
+            return {
+                overall: cachedOverall,
+                dailyTrends: cachedDailyTrends,
+                strategyStats: cachedStrategy,
+                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);
+        // 回填各子查询缓存(未命中的才写入)
+        await Promise.all([
+            cachedOverall ? Promise.resolve() : this.setCache(overallCacheKey, overall),
+            cachedDailyTrends ? Promise.resolve() : this.setCache(dailyTrendsCacheKey, dailyTrends),
+            cachedStrategy ? Promise.resolve() : this.setCache(strategyCacheKey, strategyStats),
         ]);
+        const missKeys = [
+            !cachedOverall ? "overall" : null,
+            !cachedDailyTrends ? "daily-trends" : null,
+            !cachedStrategy ? "by-strategy" : null,
+        ]
+            .filter(Boolean)
+            .join(",");
+        console.log(`[MessageStatsCache] miss key=summary:facet misses=${missKeys}`);
         this.logPerf("summary", "total", Date.now() - summaryStartedAt, {
             dailyTrendRows: this.resolveResultRows(dailyTrends),
             strategyRows: this.resolveResultRows(strategyStats),
         });
         return {
-            overall,
-            dailyTrends,
-            strategyStats,
-            pagination: {
-                page,
-                limit,
-            },
+            overall: cachedOverall || overall,
+            dailyTrends: cachedDailyTrends || dailyTrends,
+            strategyStats: cachedStrategy || strategyStats,
+            pagination: { page: safePage, limit: safeLimit },
         };
     }
     /**
@@ -466,6 +493,160 @@ class MessageRecordService {
             },
         };
     }
+    /**
+     * summary 首屏接口专用:单次 $facet 查询同时计算 overall / byStrategy / dailyTrends,
+     * 替代 3 次独立聚合,减少对同一时间窗口数据的重复扫描。
+     */
+    async runSummaryFacetQuery(matchConditions, page, limit) {
+        const startedAt = Date.now();
+        // overall 子管道(等价于 getStatisticsByGroup(matchConditions, []))
+        const overallFacet = [
+            {
+                $group: {
+                    _id: { status: "$status", inforeground: "$inforeground", uid: "$uid" },
+                    msgCount: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { status: "$_id.status", inforeground: "$_id.inforeground" },
+                    count: { $sum: "$msgCount" },
+                    uniqueUsers: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: null,
+                    totalRecords: { $sum: "$count" },
+                    ...this.getStatusAggregationFields(),
+                },
+            },
+            { $project: this.getStatisticsProjectFields([]) },
+        ];
+        // byStrategy 子管道(等价于 getStatisticsByGroup(matchConditions, ["strategyId","strategyName"], {clickThroughRate:-1}, page, limit))
+        const strategyFacet = [
+            {
+                $group: {
+                    _id: {
+                        strategyId: "$strategyId",
+                        strategyName: "$strategyName",
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    msgCount: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: {
+                        strategyId: "$_id.strategyId",
+                        strategyName: "$_id.strategyName",
+                        status: "$_id.status",
+                        inforeground: "$_id.inforeground",
+                    },
+                    count: { $sum: "$msgCount" },
+                    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: (page - 1) * limit },
+            { $limit: limit },
+        ];
+        // dailyTrends 子管道(等价于 getDailyTrendsByDimensions,不含 $match 因已在外层处理)
+        const dailyTrendsFacet = [
+            {
+                $group: {
+                    _id: {
+                        date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE } },
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
+                    msgCount: { $sum: 1 },
+                },
+            },
+            {
+                $group: {
+                    _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+                    count: { $sum: "$msgCount" },
+                    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 } },
+        ];
+        const pipeline = [
+            { $match: matchConditions },
+            {
+                $facet: {
+                    overall: overallFacet,
+                    strategyStats: strategyFacet,
+                    dailyTrends: dailyTrendsFacet,
+                },
+            },
+        ];
+        const [facetResult] = await messageRecordModel_1.MessageRecord.aggregate(pipeline).allowDiskUse(true);
+        this.logPerf("summary", "facet-query", Date.now() - startedAt, {
+            overallRows: this.resolveResultRows(facetResult?.overall?.[0]),
+            strategyRows: facetResult?.strategyStats?.length ?? 0,
+            dailyTrendRows: facetResult?.dailyTrends?.length ?? 0,
+        });
+        return {
+            overall: facetResult?.overall?.[0] ?? null,
+            strategyStats: facetResult?.strategyStats ?? [],
+            dailyTrends: facetResult?.dailyTrends ?? [],
+        };
+    }
     /**
      * 按分组获取统计数据的通用方法(支持用户点击率)
      */
@@ -564,22 +745,15 @@ class MessageRecordService {
                 matchConditions = { ...matchConditions, ...cond };
             });
             pipeline.push({ $match: matchConditions });
-            // 日期处理 - 明确创建date字段
-            pipeline.push({
-                $project: {
-                    _id: 0,
-                    date: {
-                        $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE },
-                    },
-                    status: "$status",
-                    inforeground: "$inforeground",
-                    uid: "$uid", // 保留uid用于去重统计
-                },
-            });
-            // 先按日期、状态、前景、uid 聚合,避免 $addToSet 占用大量内存
+            // 先按日期、状态、前景、uid 聚合,内联 $dateTrunc 到 $group._id,省去一次文档物化
             pipeline.push({
                 $group: {
-                    _id: { date: "$date", status: "$status", inforeground: "$inforeground", uid: "$uid" },
+                    _id: {
+                        date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE } },
+                        status: "$status",
+                        inforeground: "$inforeground",
+                        uid: "$uid",
+                    },
                     msgCount: { $sum: 1 },
                 },
             });

+ 175 - 0
oms/src/scripts/archive-messagerecords.ts

@@ -0,0 +1,175 @@
+/**
+ * archive-messagerecords.ts
+ *
+ * 将 messagerecords 热表中超过 HOT_DAYS 天的数据按月归档到
+ * messagerecords_archive_YYYYMM 集合。
+ *
+ * 用法:
+ *   npx ts-node src/scripts/archive-messagerecords.ts            # dry-run(仅统计,不写库)
+ *   npx ts-node src/scripts/archive-messagerecords.ts --execute  # 真正执行归档
+ *   npx ts-node src/scripts/archive-messagerecords.ts --execute --month 2026-02  # 仅处理指定月份
+ *
+ * 归档规则:
+ *   - 归档截止日:今天往前 HOT_DAYS 天(默认 180)的完整月末
+ *   - 幂等:重跑不重复插,使用 replaceOne+upsert by _id
+ *   - 每批 BATCH_SIZE 条,防止 OOM
+ */
+
+import mongoose, { Connection } from "mongoose";
+
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const HOT_DAYS = 180;
+const BATCH_SIZE = 500;
+
+// ---------- CLI 参数 ----------
+const isDryRun = !process.argv.includes("--execute");
+const monthArg = (() => {
+  const idx = process.argv.indexOf("--month");
+  return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+
+// ---------- 工具 ----------
+function monthStr(year: number, month: number): string {
+  return `${year}${String(month).padStart(2, "0")}`;
+}
+
+/** 计算需要归档的月份列表(完整月,不包含当前热数据月) */
+function calcArchiveMonths(hotDays: number): Array<{ yyyymm: string; start: Date; end: Date }> {
+  const now = new Date();
+  // 归档截止:当前日期 - hotDays,取当月月初为边界(保守归档完整月)
+  const cutoff = new Date(now);
+  cutoff.setDate(cutoff.getDate() - hotDays);
+  // 归档到 cutoff 所在月份的上一个月末(避免截断当月数据)
+  const cutoffMonth = new Date(cutoff.getFullYear(), cutoff.getMonth(), 1);
+
+  const months: Array<{ yyyymm: string; start: Date; end: Date }> = [];
+  // 找出 messagerecords 中最早记录的月份上限:不超过 cutoffMonth
+  // 实际起点由 findEarliestMonth 确定,这里先返回最大范围
+  // 从 2024-01 到 cutoffMonth(exclusive)
+  const start = new Date(2024, 5, 1); // 2024-06 数据起始月
+  const cursor = new Date(start);
+  while (cursor < cutoffMonth) {
+    const y = cursor.getFullYear();
+    const m = cursor.getMonth(); // 0-indexed
+    const monthStart = new Date(y, m, 1);
+    const monthEnd = new Date(y, m + 1, 1);
+    const yyyymm = monthStr(y, m + 1);
+    months.push({ yyyymm, start: monthStart, end: monthEnd });
+    cursor.setMonth(cursor.getMonth() + 1);
+  }
+  return months;
+}
+
+/** 获取归档集合(动态 model,复用 connection) */
+function getArchiveCollection(conn: Connection, yyyymm: string) {
+  const collectionName = `messagerecords_archive_${yyyymm}`;
+  // 直接操作 native collection,不走 Mongoose model,保持 schema 灵活
+  return conn.collection(collectionName);
+}
+
+// ---------- 主流程 ----------
+async function run() {
+  console.log(`[Archive] mode=${isDryRun ? "DRY-RUN" : "EXECUTE"} hotDays=${HOT_DAYS} batchSize=${BATCH_SIZE}`);
+  if (monthArg) {
+    console.log(`[Archive] scope=single month=${monthArg}`);
+  }
+
+  const conn = await mongoose.createConnection(MONGO_URI).asPromise();
+  console.log("[Archive] Connected to MongoDB.");
+
+  const hotCol = conn.collection("messagerecords");
+
+  // 确定要处理的月份列表
+  let months = calcArchiveMonths(HOT_DAYS);
+  if (monthArg) {
+    const [y, m] = monthArg.split("-").map(Number);
+    months = months.filter((mo) => mo.yyyymm === monthStr(y, m));
+    if (months.length === 0) {
+      console.error(`[Archive] ERROR: month ${monthArg} is not in archivable range (must be >= ${HOT_DAYS} days ago).`);
+      await conn.close();
+      process.exit(1);
+    }
+  }
+
+  let totalArchived = 0;
+  let totalSkipped = 0;
+
+  for (const { yyyymm, start, end } of months) {
+    const hotCount = await hotCol.countDocuments({ createdAt: { $gte: start, $lt: end } });
+    if (hotCount === 0) {
+      console.log(`[Archive] ${yyyymm}: hotCount=0, skip`);
+      continue;
+    }
+
+    const archiveCol = getArchiveCollection(conn, yyyymm);
+    const archiveCount = await archiveCol.countDocuments({ createdAt: { $gte: start, $lt: end } });
+
+    if (archiveCount >= hotCount) {
+      console.log(`[Archive] ${yyyymm}: already complete (hot=${hotCount}, archive=${archiveCount}), skip`);
+      totalSkipped += hotCount;
+      continue;
+    }
+
+    console.log(`[Archive] ${yyyymm}: hot=${hotCount} archive=${archiveCount} pending=${hotCount - archiveCount}`);
+
+    if (isDryRun) {
+      totalArchived += hotCount - archiveCount;
+      continue;
+    }
+
+    // 确保归档集合有 createdAt 索引(与热表一致)
+    await archiveCol.createIndex({ createdAt: 1 }, { background: true });
+    await archiveCol.createIndex({ uid: 1 }, { background: true });
+    await archiveCol.createIndex({ strategyName: 1, createdAt: 1 }, { background: true });
+
+    // 游标分批迁移
+    let batchInserted = 0;
+    const cursor = hotCol.find({ createdAt: { $gte: start, $lt: end } }).batchSize(BATCH_SIZE);
+
+    let batch: any[] = [];
+    for await (const doc of cursor) {
+      batch.push(doc);
+      if (batch.length >= BATCH_SIZE) {
+        const ops = batch.map((d) => ({
+          replaceOne: {
+            filter: { _id: d._id },
+            replacement: d,
+            upsert: true,
+          },
+        }));
+        const result = await archiveCol.bulkWrite(ops, { ordered: false });
+        batchInserted += result.upsertedCount + result.modifiedCount;
+        batch = [];
+        process.stdout.write(`\r[Archive] ${yyyymm}: archived ${batchInserted}/${hotCount - archiveCount} ...`);
+      }
+    }
+
+    // 处理最后一批
+    if (batch.length > 0) {
+      const ops = batch.map((d) => ({
+        replaceOne: {
+          filter: { _id: d._id },
+          replacement: d,
+          upsert: true,
+        },
+      }));
+      const result = await archiveCol.bulkWrite(ops, { ordered: false });
+      batchInserted += result.upsertedCount + result.modifiedCount;
+    }
+
+    console.log(`\n[Archive] ${yyyymm}: done inserted/updated=${batchInserted}`);
+    totalArchived += batchInserted;
+  }
+
+  console.log(`\n[Archive] SUMMARY mode=${isDryRun ? "DRY-RUN" : "EXECUTE"} totalArchived=${totalArchived} totalSkipped=${totalSkipped}`);
+  if (isDryRun) {
+    console.log("[Archive] DRY-RUN: no data was written. Re-run with --execute to apply.");
+  }
+
+  await conn.close();
+}
+
+run().catch((err) => {
+  console.error("[Archive] FATAL:", err);
+  process.exit(1);
+});

+ 186 - 0
oms/src/scripts/purge-messagerecords-hot.ts

@@ -0,0 +1,186 @@
+/**
+ * purge-messagerecords-hot.ts
+ *
+ * 从 messagerecords 热表删除已完整归档的历史数据。
+ *
+ * 安全要求(双重确认):
+ *   --execute    声明要真正执行
+ *   --confirmed  在执行前打印操作计划后,再次用此参数确认
+ *
+ * 用法:
+ *   # Step 1: 查看将要删除的月份和估算行数(dry-run,不删除)
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts
+ *
+ *   # Step 2: 确认 verify 全部通过后,执行删除
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts --execute --confirmed
+ *
+ *   # 仅删除指定月份
+ *   npx ts-node src/scripts/purge-messagerecords-hot.ts --execute --confirmed --month 2024-06
+ *
+ * 内置保护:
+ *   - 热表中仍有数据但无对应归档集合的月份,拒绝删除
+ *   - 归档数 < 热表数的月份,拒绝删除(提示先运行 archive)
+ *   - 近 HOT_DAYS 天的数据(热数据),即使有归档集合也拒绝删除
+ */
+
+import mongoose, { Connection } from "mongoose";
+
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+const HOT_DAYS = 180;
+const DELETE_BATCH_SIZE = 1000; // deleteMany 单次处理的上限,用时间窗切分
+
+const isExecute = process.argv.includes("--execute");
+const isConfirmed = process.argv.includes("--confirmed");
+
+const monthArg = (() => {
+  const idx = process.argv.indexOf("--month");
+  return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+
+// ---------- 工具 ----------
+function parseArchiveMonthFromName(name: string): string | null {
+  const m = name.match(/^messagerecords_archive_(\d{6})$/);
+  return m ? m[1] : null;
+}
+
+function yyyymmToRange(yyyymm: string): { start: Date; end: Date } {
+  const year = parseInt(yyyymm.slice(0, 4), 10);
+  const month = parseInt(yyyymm.slice(4, 6), 10) - 1; // 0-indexed
+  return {
+    start: new Date(year, month, 1),
+    end: new Date(year, month + 1, 1),
+  };
+}
+
+function calcHotCutoffStart(): Date {
+  const d = new Date();
+  d.setDate(d.getDate() - HOT_DAYS);
+  // 保守取当月月初,整月视为热数据
+  return new Date(d.getFullYear(), d.getMonth(), 1);
+}
+
+// ---------- 主流程 ----------
+async function run() {
+  const dryRun = !(isExecute && isConfirmed);
+  console.log(`[Purge] mode=${dryRun ? "DRY-RUN" : "EXECUTE"} hotDays=${HOT_DAYS}`);
+
+  if (isExecute && !isConfirmed) {
+    console.warn("[Purge] WARNING: --execute requires --confirmed as a second confirmation. Running in DRY-RUN mode.");
+  }
+
+  const conn = await mongoose.createConnection(MONGO_URI).asPromise();
+  console.log("[Purge] Connected to MongoDB.");
+
+  const hotCol = conn.collection("messagerecords");
+  const hotCutoff = calcHotCutoffStart();
+
+  // 获取所有 archive 集合
+  const allCollections = await conn.db.listCollections().toArray();
+  const archiveMap = new Map<string, string>(); // yyyymm -> collectionName
+  for (const c of allCollections) {
+    const yyyymm = parseArchiveMonthFromName(c.name);
+    if (yyyymm) archiveMap.set(yyyymm, c.name);
+  }
+
+  // 找出热表中所有有数据、且早于 hotCutoff 的月份
+  // 用聚合按月分组统计
+  const pipeline = [
+    { $match: { createdAt: { $lt: hotCutoff } } },
+    {
+      $group: {
+        _id: { $dateToString: { format: "%Y%m", date: "$createdAt" } },
+        count: { $sum: 1 },
+      },
+    },
+    { $sort: { _id: 1 } },
+  ];
+
+  console.log("[Purge] Scanning hot table for purgeable months (this may take a moment)...");
+  const hotMonths: Array<{ yyyymm: string; hotCount: number }> = [];
+  const cursor = hotCol.aggregate(pipeline, { allowDiskUse: true, maxTimeMS: 120000 });
+  for await (const row of cursor) {
+    hotMonths.push({ yyyymm: row._id as string, hotCount: row.count as number });
+  }
+
+  if (hotMonths.length === 0) {
+    console.log("[Purge] No purgeable data found in hot table (all within hot window or already purged).");
+    await conn.close();
+    process.exit(0);
+  }
+
+  // 过滤单月
+  let targetMonths = hotMonths;
+  if (monthArg) {
+    const yyyymm = monthArg.replace("-", "");
+    targetMonths = hotMonths.filter((m) => m.yyyymm === yyyymm);
+    if (targetMonths.length === 0) {
+      console.error(`[Purge] ERROR: month ${monthArg} has no purgeable data in hot table.`);
+      await conn.close();
+      process.exit(1);
+    }
+  }
+
+  // 预检:每个目标月份都必须有完整归档
+  console.log(`\n[Purge] Pre-check: verifying archive completeness for ${targetMonths.length} month(s)...`);
+  const blockers: string[] = [];
+  const plan: Array<{ yyyymm: string; hotCount: number; archiveCount: number }> = [];
+
+  for (const { yyyymm, hotCount } of targetMonths) {
+    const { start, end } = yyyymmToRange(yyyymm);
+    if (!archiveMap.has(yyyymm)) {
+      console.log(`[Purge] ${yyyymm}: NO ARCHIVE COLLECTION → BLOCKED`);
+      blockers.push(yyyymm);
+      continue;
+    }
+    const archiveCount = await conn.collection(archiveMap.get(yyyymm)!).countDocuments({ createdAt: { $gte: start, $lt: end } });
+    if (archiveCount < hotCount) {
+      console.log(`[Purge] ${yyyymm}: INCOMPLETE (hot=${hotCount} archive=${archiveCount}) → BLOCKED`);
+      blockers.push(yyyymm);
+    } else {
+      console.log(`[Purge] ${yyyymm}: OK (hot=${hotCount.toLocaleString()} archive=${archiveCount.toLocaleString()})`);
+      plan.push({ yyyymm, hotCount, archiveCount });
+    }
+  }
+
+  if (blockers.length > 0) {
+    console.error(`\n[Purge] ABORT: ${blockers.length} month(s) blocked (${blockers.join(", ")}). Run archive-messagerecords.ts --execute first.`);
+    await conn.close();
+    process.exit(1);
+  }
+
+  // 打印执行计划
+  const totalRows = plan.reduce((s, p) => s + p.hotCount, 0);
+  console.log(`\n[Purge] PLAN: will delete ${totalRows.toLocaleString()} rows from messagerecords across ${plan.length} month(s):`);
+  for (const { yyyymm, hotCount } of plan) {
+    console.log(`  ${yyyymm}: ${hotCount.toLocaleString()} rows`);
+  }
+
+  if (dryRun) {
+    console.log("\n[Purge] DRY-RUN: no data deleted. Re-run with --execute --confirmed to apply.");
+    await conn.close();
+    process.exit(0);
+  }
+
+  // 执行删除
+  console.log("\n[Purge] Starting deletion...");
+  let totalDeleted = 0;
+
+  for (const { yyyymm, hotCount } of plan) {
+    const { start, end } = yyyymmToRange(yyyymm);
+    const t0 = Date.now();
+    const result = await hotCol.deleteMany({ createdAt: { $gte: start, $lt: end } });
+    const elapsed = Date.now() - t0;
+    console.log(`[Purge] ${yyyymm}: deleted=${result.deletedCount.toLocaleString()} (expected≈${hotCount.toLocaleString()}) elapsed=${elapsed}ms`);
+    totalDeleted += result.deletedCount;
+  }
+
+  console.log(`\n[Purge] DONE: total deleted=${totalDeleted.toLocaleString()} rows from messagerecords hot table.`);
+  console.log("[Purge] Recommend running verify-messagerecords-archive.ts again to confirm integrity.");
+
+  await conn.close();
+}
+
+run().catch((err) => {
+  console.error("[Purge] FATAL:", err);
+  process.exit(1);
+});

+ 117 - 0
oms/src/scripts/verify-messagerecords-archive.ts

@@ -0,0 +1,117 @@
+/**
+ * verify-messagerecords-archive.ts
+ *
+ * 校验 messagerecords_archive_YYYYMM 归档集合与热表的数据完整性。
+ * 对每个已存在的归档集合,按月比较热表与归档的记录数。
+ *
+ * 用法:
+ *   npx ts-node src/scripts/verify-messagerecords-archive.ts             # 检查所有归档月份
+ *   npx ts-node src/scripts/verify-messagerecords-archive.ts --month 2024-06  # 仅检查指定月份
+ *
+ * 输出:
+ *   每月打印 hotCount / archiveCount 以及 PASS / FAIL
+ *   最终汇总 PASS 月数 / FAIL 月数
+ *   如有 FAIL,退出码为 1(供脚本链式调用判断)
+ */
+
+import mongoose, { Connection } from "mongoose";
+
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+
+const monthArg = (() => {
+  const idx = process.argv.indexOf("--month");
+  return idx !== -1 ? process.argv[idx + 1] : null;
+})();
+
+function parseArchiveMonthFromName(name: string): string | null {
+  const m = name.match(/^messagerecords_archive_(\d{6})$/);
+  return m ? m[1] : null;
+}
+
+function yyyymmToRange(yyyymm: string): { start: Date; end: Date } {
+  const year = parseInt(yyyymm.slice(0, 4), 10);
+  const month = parseInt(yyyymm.slice(4, 6), 10) - 1; // 0-indexed
+  return {
+    start: new Date(year, month, 1),
+    end: new Date(year, month + 1, 1),
+  };
+}
+
+async function run() {
+  console.log("[Verify] Connecting to MongoDB...");
+  const conn = await mongoose.createConnection(MONGO_URI).asPromise();
+  console.log("[Verify] Connected.");
+
+  const hotCol = conn.collection("messagerecords");
+
+  // 获取所有 archive 集合
+  const allCollections = await conn.db.listCollections().toArray();
+  let archiveCollections = allCollections
+    .map((c) => c.name)
+    .filter((name) => parseArchiveMonthFromName(name) !== null)
+    .sort();
+
+  if (archiveCollections.length === 0) {
+    console.log("[Verify] No archive collections found. Run archive-messagerecords.ts --execute first.");
+    await conn.close();
+    process.exit(0);
+  }
+
+  // 过滤单月
+  if (monthArg) {
+    const yyyymm = monthArg.replace("-", "");
+    archiveCollections = archiveCollections.filter((name) => name.endsWith(yyyymm));
+    if (archiveCollections.length === 0) {
+      console.error(`[Verify] ERROR: No archive collection found for month ${monthArg}`);
+      await conn.close();
+      process.exit(1);
+    }
+  }
+
+  console.log(`[Verify] Checking ${archiveCollections.length} archive collection(s)...\n`);
+
+  let passCount = 0;
+  let failCount = 0;
+  const failMonths: string[] = [];
+
+  for (const colName of archiveCollections) {
+    const yyyymm = parseArchiveMonthFromName(colName)!;
+    const { start, end } = yyyymmToRange(yyyymm);
+
+    const [hotCount, archiveCount] = await Promise.all([
+      hotCol.countDocuments({ createdAt: { $gte: start, $lt: end } }),
+      conn.collection(colName).countDocuments({ createdAt: { $gte: start, $lt: end } }),
+    ]);
+
+    const status = archiveCount >= hotCount ? "PASS" : "FAIL";
+    const diff = archiveCount - hotCount;
+    const diffStr = diff >= 0 ? `+${diff}` : `${diff}`;
+
+    console.log(
+      `[Verify] ${yyyymm}: hot=${hotCount.toLocaleString().padStart(10)} archive=${archiveCount.toLocaleString().padStart(10)} diff=${diffStr.padStart(7)} → ${status}`
+    );
+
+    if (status === "PASS") {
+      passCount++;
+    } else {
+      failCount++;
+      failMonths.push(yyyymm);
+    }
+  }
+
+  console.log(`\n[Verify] SUMMARY: PASS=${passCount} FAIL=${failCount}`);
+  if (failMonths.length > 0) {
+    console.log(`[Verify] FAILED months: ${failMonths.join(", ")}`);
+    console.log("[Verify] Re-run archive-messagerecords.ts --execute for failed months before purging.");
+  } else {
+    console.log("[Verify] All archive collections are complete. Safe to run purge-messagerecords-hot.ts.");
+  }
+
+  await conn.close();
+  process.exit(failCount > 0 ? 1 : 0);
+}
+
+run().catch((err) => {
+  console.error("[Verify] FATAL:", err);
+  process.exit(1);
+});

+ 247 - 49
oms/src/services/messageRecordService.ts

@@ -275,48 +275,88 @@ export class MessageRecordService {
   ) {
     const summaryStartedAt = Date.now();
 
-    const [overall, dailyTrends, strategyStats] = await Promise.all([
-      (async () => {
-        const startedAt = Date.now();
-        const result = await this.getOverallStatistics(startDate, endDate, strategyName);
-        this.logPerf("summary", "overall", Date.now() - startedAt, {
-          resultRows: this.resolveResultRows(result),
-        });
-        return result;
-      })(),
-      (async () => {
-        const startedAt = Date.now();
-        const result = await this.getDailySentTrends(startDate, endDate, strategyName);
-        this.logPerf("summary", "daily-trends", Date.now() - startedAt, {
-          resultRows: this.resolveResultRows(result),
-        });
-        return result;
-      })(),
-      (async () => {
-        const startedAt = Date.now();
-        const result = await this.getStatisticsByStrategy(startDate, endDate, strategyName, page, limit);
-        this.logPerf("summary", "by-strategy", Date.now() - startedAt, {
-          resultRows: this.resolveResultRows(result),
-          page,
-          limit,
-        });
-        return result;
-      })(),
+    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))
+    );
+
+    // 预先构建 3 个子查询的 cache key,与各自方法保持完全一致
+    const overallCacheKey = this.buildStatsCacheKey("overall", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+    });
+    const dailyTrendsCacheKey = this.buildStatsCacheKey("daily-trends", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+    });
+    const strategyCacheKey = this.buildStatsCacheKey("by-strategy", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+      page: safePage,
+      limit: safeLimit,
+    });
+
+    // 并行检查全部缓存
+    const [cachedOverall, cachedDailyTrends, cachedStrategy] = await Promise.all([
+      this.getCache(overallCacheKey),
+      this.getCache(dailyTrendsCacheKey),
+      this.getCache(strategyCacheKey),
     ]);
 
+    if (cachedOverall && cachedDailyTrends && cachedStrategy) {
+      // 全部命中,直接返回
+      console.log(`[MessageStatsCache] hit key=summary:all-3`);
+      this.logPerf("summary", "total", Date.now() - summaryStartedAt, {
+        cacheHit: true,
+        dailyTrendRows: this.resolveResultRows(cachedDailyTrends),
+        strategyRows: this.resolveResultRows(cachedStrategy),
+      });
+      return {
+        overall: cachedOverall,
+        dailyTrends: cachedDailyTrends,
+        strategyStats: cachedStrategy,
+        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
+    );
+
+    // 回填各子查询缓存(未命中的才写入)
+    await Promise.all([
+      cachedOverall ? Promise.resolve() : this.setCache(overallCacheKey, overall),
+      cachedDailyTrends ? Promise.resolve() : this.setCache(dailyTrendsCacheKey, dailyTrends),
+      cachedStrategy ? Promise.resolve() : this.setCache(strategyCacheKey, strategyStats),
+    ]);
+
+    const missKeys = [
+      !cachedOverall ? "overall" : null,
+      !cachedDailyTrends ? "daily-trends" : null,
+      !cachedStrategy ? "by-strategy" : null,
+    ]
+      .filter(Boolean)
+      .join(",");
+    console.log(`[MessageStatsCache] miss key=summary:facet misses=${missKeys}`);
+
     this.logPerf("summary", "total", Date.now() - summaryStartedAt, {
       dailyTrendRows: this.resolveResultRows(dailyTrends),
       strategyRows: this.resolveResultRows(strategyStats),
     });
 
     return {
-      overall,
-      dailyTrends,
-      strategyStats,
-      pagination: {
-        page,
-        limit,
-      },
+      overall: (cachedOverall as any) || overall,
+      dailyTrends: (cachedDailyTrends as any) || dailyTrends,
+      strategyStats: (cachedStrategy as any) || strategyStats,
+      pagination: { page: safePage, limit: safeLimit },
     };
   }
 
@@ -583,6 +623,172 @@ export class MessageRecordService {
     };
   }
 
+  /**
+   * summary 首屏接口专用:单次 $facet 查询同时计算 overall / byStrategy / dailyTrends,
+   * 替代 3 次独立聚合,减少对同一时间窗口数据的重复扫描。
+   */
+  private async runSummaryFacetQuery(
+    matchConditions: any,
+    page: number,
+    limit: number
+  ): Promise<{ overall: any; strategyStats: any[]; dailyTrends: any[] }> {
+    const startedAt = Date.now();
+
+    // overall 子管道(等价于 getStatisticsByGroup(matchConditions, []))
+    const overallFacet: any[] = [
+      {
+        $group: {
+          _id: { status: "$status", inforeground: "$inforeground", uid: "$uid" },
+          msgCount: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { status: "$_id.status", inforeground: "$_id.inforeground" },
+          count: { $sum: "$msgCount" },
+          uniqueUsers: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: null,
+          totalRecords: { $sum: "$count" },
+          ...this.getStatusAggregationFields(),
+        },
+      },
+      { $project: this.getStatisticsProjectFields([]) },
+    ];
+
+    // byStrategy 子管道(等价于 getStatisticsByGroup(matchConditions, ["strategyId","strategyName"], {clickThroughRate:-1}, page, limit))
+    const strategyFacet: any[] = [
+      {
+        $group: {
+          _id: {
+            strategyId: "$strategyId",
+            strategyName: "$strategyName",
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          msgCount: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: {
+            strategyId: "$_id.strategyId",
+            strategyName: "$_id.strategyName",
+            status: "$_id.status",
+            inforeground: "$_id.inforeground",
+          },
+          count: { $sum: "$msgCount" },
+          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: (page - 1) * limit },
+      { $limit: limit },
+    ];
+
+    // dailyTrends 子管道(等价于 getDailyTrendsByDimensions,不含 $match 因已在外层处理)
+    const dailyTrendsFacet: any[] = [
+      {
+        $group: {
+          _id: {
+            date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE } },
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
+          msgCount: { $sum: 1 },
+        },
+      },
+      {
+        $group: {
+          _id: { date: "$_id.date", status: "$_id.status", inforeground: "$_id.inforeground" },
+          count: { $sum: "$msgCount" },
+          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 } },
+    ];
+
+    const pipeline: any[] = [
+      { $match: matchConditions },
+      {
+        $facet: {
+          overall: overallFacet,
+          strategyStats: strategyFacet,
+          dailyTrends: dailyTrendsFacet,
+        },
+      },
+    ];
+
+    const [facetResult] = await MessageRecord.aggregate(pipeline).allowDiskUse(true);
+
+    this.logPerf("summary", "facet-query", Date.now() - startedAt, {
+      overallRows: this.resolveResultRows(facetResult?.overall?.[0]),
+      strategyRows: facetResult?.strategyStats?.length ?? 0,
+      dailyTrendRows: facetResult?.dailyTrends?.length ?? 0,
+    });
+
+    return {
+      overall: facetResult?.overall?.[0] ?? null,
+      strategyStats: facetResult?.strategyStats ?? [],
+      dailyTrends: facetResult?.dailyTrends ?? [],
+    };
+  }
+
   /**
    * 按分组获取统计数据的通用方法(支持用户点击率)
    */
@@ -705,23 +911,15 @@ export class MessageRecordService {
 
       pipeline.push({ $match: matchConditions });
 
-      // 日期处理 - 明确创建date字段
-      pipeline.push({
-        $project: {
-          _id: 0,
-          date: {
-            $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE },
-          },
-          status: "$status",
-          inforeground: "$inforeground",
-          uid: "$uid", // 保留uid用于去重统计
-        },
-      });
-
-      // 先按日期、状态、前景、uid 聚合,避免 $addToSet 占用大量内存
+      // 先按日期、状态、前景、uid 聚合,内联 $dateTrunc 到 $group._id,省去一次文档物化
       pipeline.push({
         $group: {
-          _id: { date: "$date", status: "$status", inforeground: "$inforeground", uid: "$uid" },
+          _id: {
+            date: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: MessageRecordService.TIMEZONE } },
+            status: "$status",
+            inforeground: "$inforeground",
+            uid: "$uid",
+          },
           msgCount: { $sum: 1 },
         },
       });