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