namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8; /// /// S8-DASHBOARD-P2-SEMANTICS-1:日粒度趋势的「桶枚举」。 /// /// 要解决的问题:趋势按 CreatedAt.Date(零点)分组,而 /// 的 last_24h 返回的是 [Now-24h, Now) /// 这种非零点窗口。原实现直接拿 from.AddDays(i) 当桶键去查分组字典, /// 键上带着 17:25:46,与零点键永远对不上 ⇒ 窗口内明明有行,峰值 / 均值 / 今日 /// 却全是 0。 /// /// 本类确立的口径:桶 = 查询窗口所跨越的自然日。 /// 窗口非零点对齐时,首尾桶承载的是「该自然日落在窗口内的那一段」, /// 因此 Σ桶 == 窗口内行数 恒成立——这是趋势图与任何独立 SQL 核对的唯一不变量。 /// /// 窗口只收紧、不放宽:桶对齐绝不把查询范围撑大,否则会把窗口外的行算进来。 /// 桶数超过上限时保留最近的 N 天并同步收紧查询窗口(而不是砍掉尾巴、把已查出的行 /// 静默丢弃),并通过 自报。 /// public static class S8TrendBucketHelper { /// 日桶上限。与既有 period 分支一致(季度最长 92 天,超出部分保留最近 90 天)。 public const int MaxBuckets = 90; /// /// 桶枚举结果。 /// /// 实际应下发到 SQL 的窗口起点(含)。 /// 实际应下发到 SQL 的窗口终点(不含)。 /// 零点对齐的桶键,升序,与分组字典的 DateTime.Date 键同构。 /// 首桶或末桶只承载了该自然日的一部分(如 last_24h)。 /// 为 true 时「今日 vs 昨日」环比不可比,调用方应置 null。 /// 原窗口跨越的自然日超过上限,已保留最近 N 天。 public sealed record DayBucketPlan( DateTime QueryFrom, DateTime QueryToExclusive, IReadOnlyList Buckets, bool HasPartialEdge, bool IsTruncated); /// /// 把任意半开窗口 [from, toExclusive) 映射为日桶计划。 /// public static DayBucketPlan Resolve(DateTime from, DateTime toExclusive, int maxBuckets = MaxBuckets) { if (maxBuckets < 1) maxBuckets = 1; // 退化窗口(倒挂或零长)兜底为「from 所在自然日」的 1 个桶,不抛异常。 if (toExclusive <= from) toExclusive = from.AddTicks(1); var firstDay = from.Date; var lastDay = toExclusive.AddTicks(-1).Date; // 半开区间右端所属的最后一个自然日 var span = (int)(lastDay - firstDay).TotalDays + 1; var truncated = span > maxBuckets; if (truncated) { firstDay = lastDay.AddDays(-(maxBuckets - 1)); span = maxBuckets; } var bucketEnd = lastDay.AddDays(1); var queryFrom = from > firstDay ? from : firstDay; var queryTo = toExclusive < bucketEnd ? toExclusive : bucketEnd; var buckets = new List(span); for (var i = 0; i < span; i++) buckets.Add(firstDay.AddDays(i)); var hasPartialEdge = queryFrom != firstDay || queryTo != bucketEnd; return new DayBucketPlan(queryFrom, queryTo, buckets, hasPartialEdge, truncated); } }