S8WatchRulePreviewService.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  7. /// <summary>
  8. /// S8-RULE01-INTEGRATION-PREVIEW-1:监控规则的**只读预演**。
  9. ///
  10. /// <b>解决的问题</b>:在此之前,想知道「一条规则启用后会命中什么」只有一条路 ——
  11. /// 把它 enabled=true 然后等调度器跑。对 Rule 01 而言那意味着一次性生成 28 条真实异常单,
  12. /// 而验证只需要 1 条。没有中间档位,等于逼着人拿异常中心当试验场。
  13. ///
  14. /// <b>本服务走的是与生产完全相同的取数与判定链</b>:
  15. /// <code>
  16. /// rule.DatasetCode → S8MonitoringDataGateway → Catalog → Provider → canonical
  17. /// → 对应 evaluator(按 rule_type 分派) → hits
  18. /// </code>
  19. /// 不是另写一份预估 SQL —— 那样预演结果与真实运行必然漂移,预演也就失去意义。
  20. ///
  21. /// <b>写入边界(本服务的全部价值都建立在这条上)</b>:
  22. /// · 不写 ado_s8_detection_log
  23. /// · 不写 ado_s8_exception / timeline / notification_log
  24. /// · 不写 ado_s8_rule_detection_state(不推进抗抖计数)
  25. /// · 不改规则自身的任何列(不动 enabled / next_run_at / last_run_at / lock_*)
  26. /// · 不发通知
  27. /// 只读既有 exception 用于回答「这条命中现在是否已有活动异常」。
  28. ///
  29. /// <b>enabled=false 的规则同样可预演</b>——这正是本服务存在的理由:先看清楚再决定启不启用。
  30. /// </summary>
  31. public class S8WatchRulePreviewService : ITransient
  32. {
  33. private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
  34. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  35. private readonly S8MonitoringDataGateway _gateway;
  36. private readonly IS8RuleCatalog _ruleCatalog;
  37. private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
  38. private readonly S8ShortageRuleEvaluator _shortageEvaluator;
  39. private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
  40. public S8WatchRulePreviewService(
  41. SqlSugarRepository<AdoS8WatchRule> ruleRep,
  42. SqlSugarRepository<AdoS8Exception> exceptionRep,
  43. S8MonitoringDataGateway gateway,
  44. IS8RuleCatalog ruleCatalog,
  45. S8TimeoutRuleEvaluator timeoutEvaluator,
  46. S8ShortageRuleEvaluator shortageEvaluator,
  47. S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
  48. {
  49. _ruleRep = ruleRep;
  50. _exceptionRep = exceptionRep;
  51. _gateway = gateway;
  52. _ruleCatalog = ruleCatalog;
  53. _timeoutEvaluator = timeoutEvaluator;
  54. _shortageEvaluator = shortageEvaluator;
  55. _outOfRangeEvaluator = outOfRangeEvaluator;
  56. }
  57. /// <summary>
  58. /// 预演一条规则。<paramref name="scope"/> 由 Controller 用服务端可信作用域盖章,
  59. /// 规则按 (id, tenant, factory) 绑行 —— 越权 id 一律按不存在处理,不泄露他租户规则是否存在。
  60. /// </summary>
  61. public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
  62. {
  63. var rule = await _ruleRep.AsQueryable()
  64. .Where(x => x.Id == id && x.TenantId == scope.TenantId)
  65. .FirstAsync() ?? throw new S8NotFoundException();
  66. // S8-RULE-GOVERNANCE-BATCH1:预演必须与真实运行走同一份语义来源。
  67. // 若这里读 rule.RuleType(DB 投影列)而 evaluator 读定义,两者一旦不一致,
  68. // 预演结果就会与真跑结果不同 —— 而预演的全部价值就建立在"两者相同"上。
  69. var definition = _ruleCatalog.GetRequired(rule.RuleCode);
  70. var ruleType = definition.RuleType;
  71. IS8RuleEvaluator evaluator = ruleType switch
  72. {
  73. S8TimeoutRuleEvaluator.RuleTypeCode => _timeoutEvaluator,
  74. S8ShortageRuleEvaluator.RuleTypeCode => _shortageEvaluator,
  75. S8OutOfRangeRuleEvaluator.RuleTypeCode => _outOfRangeEvaluator,
  76. _ => throw new S8BizException($"不支持的规则类型:{ruleType}")
  77. };
  78. // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
  79. // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
  80. var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
  81. var data = await _gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows);
  82. var candidateRows = data.RowSet.Rows.Count;
  83. var hits = await evaluator.EvaluateAsync(scope.TenantId, rule);
  84. // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
  85. // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
  86. var dedupKeys = hits.Select(h => h.DedupKey).Where(k => !string.IsNullOrWhiteSpace(k)).Distinct().ToList();
  87. var activeByDedupKey = dedupKeys.Count == 0
  88. ? new Dictionary<string, long>(StringComparer.Ordinal)
  89. : (await _exceptionRep.AsQueryable()
  90. // S8-TENANT-ONLY-BATCH5:活动异常按 Tenant + dedup_key 查,不再按工厂切。
  91. // 预演必须与真跑同源:调度器侧的 FindOpenExceptionByDedupKeyAsync 也已只认 Tenant,
  92. // 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信。
  93. .Where(x => x.TenantId == scope.TenantId
  94. && !x.IsDeleted && x.Status != "CLOSED"
  95. && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
  96. .Select(x => new { x.DedupKey, x.Id })
  97. .ToListAsync())
  98. .GroupBy(x => x.DedupKey!, StringComparer.Ordinal)
  99. .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
  100. return new S8WatchRulePreviewResult
  101. {
  102. RuleId = rule.Id,
  103. RuleCode = rule.RuleCode,
  104. RuleType = ruleType,
  105. DatasetCode = definition.DatasetCode,
  106. Enabled = rule.Enabled,
  107. TenantId = scope.TenantId,
  108. EvaluatedAt = DateTime.Now,
  109. CandidateRows = candidateRows,
  110. HitCount = hits.Count,
  111. // 未命中数 = 候选 - 命中。刻意不细分「已完成」与「未到期」:
  112. // 那需要 preview 自己复刻一遍 evaluator 的判定分支,一旦两边逻辑漂移,
  113. // 预演就会给出与真实运行不同的解释。宁可少报,不可假报。
  114. NoHitRows = Math.Max(0, candidateRows - hits.Count),
  115. WouldCreateCount = CountWouldCreate(hits, activeByDedupKey),
  116. WouldMatchExistingCount = CountWouldMatchExisting(hits, activeByDedupKey),
  117. Hits = hits.Select(h => new S8WatchRulePreviewHit
  118. {
  119. SourceObjectType = h.SourceObjectType,
  120. SourceObjectId = h.SourceObjectId,
  121. RelatedObjectCode = h.RelatedObjectCode,
  122. RelatedObjectName = h.Title,
  123. ExceptionTypeCode = h.ExceptionTypeCode,
  124. Severity = h.Severity,
  125. DedupKey = h.DedupKey,
  126. DetectedAt = h.DetectedAt,
  127. ExistingActiveExceptionId =
  128. activeByDedupKey.TryGetValue(h.DedupKey ?? string.Empty, out var exId) ? exId : null
  129. }).ToList()
  130. };
  131. }
  132. /// <summary>
  133. /// S8-RULE-LIFECYCLE-CREATE-GATE-1:「若此刻真的执行,会<b>新建</b>几条」。
  134. ///
  135. /// <para>判据必须与调度器 <c>ProcessSingleRuleAsync</c> 的建单条件<b>同源</b> ——
  136. /// 那里除了去重,还有一道 <see cref="S8RuleHit.CreateEligible"/> 闸门。
  137. /// 少判这一项,预演就会系统性高估:Rule 02 本地实测中预演报「将新建 3」,
  138. /// 而真跑 <c>created=2</c>,差的正是那条已过交期、风险已兑现的历史行。
  139. /// 管理员正是拿这个数字决定要不要启用,<b>报大了比不报更糟</b>。</para>
  140. ///
  141. /// <para>抽成纯函数只为一件事:让这条口径能被直接断言。原先它是
  142. /// <c>RunAsync</c> 里的一个内联 lambda,而 <c>RunAsync</c> 需要 DB + Gateway,
  143. /// 于是这条最容易悄悄漂移的算术反而是整个服务里唯一测不到的部分。</para>
  144. /// </summary>
  145. internal static int CountWouldCreate(
  146. IReadOnlyCollection<S8RuleHit> hits, IReadOnlyDictionary<string, long> activeByDedupKey) =>
  147. hits.Count(h => h.CreateEligible && !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty));
  148. /// <summary>
  149. /// 「若此刻真的执行,会<b>刷新既有</b>几条」。
  150. ///
  151. /// <para><b>刻意不加 <see cref="S8RuleHit.CreateEligible"/> 过滤</b>:闸门在调度器里位于
  152. /// 「既有异常刷新」<b>之后</b>。一条跨过到期日的既有预警 <c>CreateEligible</c> 已经是 false,
  153. /// 但它照样会被刷新以保持 active —— 这正是假恢复修复的核心。这里跟着加过滤,
  154. /// 预演就会把「保持 active」误报成「什么都不会发生」,等于用另一种方式
  155. /// 把那条风险从管理员视野里抹掉。</para>
  156. /// </summary>
  157. internal static int CountWouldMatchExisting(
  158. IReadOnlyCollection<S8RuleHit> hits, IReadOnlyDictionary<string, long> activeByDedupKey) =>
  159. hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty));
  160. }
  161. /// <summary>预演结果。纯读,不代表任何已发生的副作用。</summary>
  162. public sealed class S8WatchRulePreviewResult
  163. {
  164. public long RuleId { get; init; }
  165. public string RuleCode { get; init; } = string.Empty;
  166. public string RuleType { get; init; } = string.Empty;
  167. public string? DatasetCode { get; init; }
  168. /// <summary>规则当前是否启用。预演**不受此值影响**,也不修改它。</summary>
  169. public bool Enabled { get; init; }
  170. public long TenantId { get; init; }
  171. public long FactoryId { get; init; }
  172. public DateTime EvaluatedAt { get; init; }
  173. /// <summary>Provider 返回的候选行数(判定之前)。</summary>
  174. public int CandidateRows { get; init; }
  175. public int HitCount { get; init; }
  176. public int NoHitRows { get; init; }
  177. /// <summary>若此刻真的执行,会新建的异常数。</summary>
  178. public int WouldCreateCount { get; init; }
  179. /// <summary>若此刻真的执行,会命中既有活动异常(即刷新而非新建)的数量。</summary>
  180. public int WouldMatchExistingCount { get; init; }
  181. public List<S8WatchRulePreviewHit> Hits { get; init; } = new();
  182. }
  183. /// <summary>单条预演命中。</summary>
  184. public sealed class S8WatchRulePreviewHit
  185. {
  186. public string SourceObjectType { get; init; } = string.Empty;
  187. public string SourceObjectId { get; init; } = string.Empty;
  188. public string RelatedObjectCode { get; init; } = string.Empty;
  189. public string RelatedObjectName { get; init; } = string.Empty;
  190. public string ExceptionTypeCode { get; init; } = string.Empty;
  191. public string Severity { get; init; } = string.Empty;
  192. public string DedupKey { get; init; } = string.Empty;
  193. public DateTime DetectedAt { get; init; }
  194. /// <summary>非空表示该命中已有活动异常,真实执行时是刷新而不是新建。</summary>
  195. public long? ExistingActiveExceptionId { get; init; }
  196. }