S8TrendBucketHelper.cs 3.6 KB

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