S8WatchRulePreviewService.cs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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 = hits.Count(h => !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
  116. WouldMatchExistingCount = hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
  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. }
  133. /// <summary>预演结果。纯读,不代表任何已发生的副作用。</summary>
  134. public sealed class S8WatchRulePreviewResult
  135. {
  136. public long RuleId { get; init; }
  137. public string RuleCode { get; init; } = string.Empty;
  138. public string RuleType { get; init; } = string.Empty;
  139. public string? DatasetCode { get; init; }
  140. /// <summary>规则当前是否启用。预演**不受此值影响**,也不修改它。</summary>
  141. public bool Enabled { get; init; }
  142. public long TenantId { get; init; }
  143. public long FactoryId { get; init; }
  144. public DateTime EvaluatedAt { get; init; }
  145. /// <summary>Provider 返回的候选行数(判定之前)。</summary>
  146. public int CandidateRows { get; init; }
  147. public int HitCount { get; init; }
  148. public int NoHitRows { get; init; }
  149. /// <summary>若此刻真的执行,会新建的异常数。</summary>
  150. public int WouldCreateCount { get; init; }
  151. /// <summary>若此刻真的执行,会命中既有活动异常(即刷新而非新建)的数量。</summary>
  152. public int WouldMatchExistingCount { get; init; }
  153. public List<S8WatchRulePreviewHit> Hits { get; init; } = new();
  154. }
  155. /// <summary>单条预演命中。</summary>
  156. public sealed class S8WatchRulePreviewHit
  157. {
  158. public string SourceObjectType { get; init; } = string.Empty;
  159. public string SourceObjectId { get; init; } = string.Empty;
  160. public string RelatedObjectCode { get; init; } = string.Empty;
  161. public string RelatedObjectName { get; init; } = string.Empty;
  162. public string ExceptionTypeCode { get; init; } = string.Empty;
  163. public string Severity { get; init; } = string.Empty;
  164. public string DedupKey { get; init; } = string.Empty;
  165. public DateTime DetectedAt { get; init; }
  166. /// <summary>非空表示该命中已有活动异常,真实执行时是刷新而不是新建。</summary>
  167. public long? ExistingActiveExceptionId { get; init; }
  168. }