|
@@ -189,46 +189,73 @@ class MessageRecordService {
|
|
|
*/
|
|
*/
|
|
|
async getStatisticsSummary(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
|
|
async getStatisticsSummary(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
|
|
|
const summaryStartedAt = Date.now();
|
|
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, {
|
|
this.logPerf("summary", "total", Date.now() - summaryStartedAt, {
|
|
|
dailyTrendRows: this.resolveResultRows(dailyTrends),
|
|
dailyTrendRows: this.resolveResultRows(dailyTrends),
|
|
|
strategyRows: this.resolveResultRows(strategyStats),
|
|
strategyRows: this.resolveResultRows(strategyStats),
|
|
|
});
|
|
});
|
|
|
return {
|
|
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 };
|
|
matchConditions = { ...matchConditions, ...cond };
|
|
|
});
|
|
});
|
|
|
pipeline.push({ $match: matchConditions });
|
|
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({
|
|
pipeline.push({
|
|
|
$group: {
|
|
$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 },
|
|
msgCount: { $sum: 1 },
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|