|
|
@@ -0,0 +1,180 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-RULE01-INTEGRATION-PREVIEW-1:监控规则的**只读预演**。
|
|
|
+///
|
|
|
+/// <b>解决的问题</b>:在此之前,想知道「一条规则启用后会命中什么」只有一条路 ——
|
|
|
+/// 把它 enabled=true 然后等调度器跑。对 Rule 01 而言那意味着一次性生成 28 条真实异常单,
|
|
|
+/// 而验证只需要 1 条。没有中间档位,等于逼着人拿异常中心当试验场。
|
|
|
+///
|
|
|
+/// <b>本服务走的是与生产完全相同的取数与判定链</b>:
|
|
|
+/// <code>
|
|
|
+/// rule.DatasetCode → S8MonitoringDataGateway → Catalog → Provider → canonical
|
|
|
+/// → 对应 evaluator(按 rule_type 分派) → hits
|
|
|
+/// </code>
|
|
|
+/// 不是另写一份预估 SQL —— 那样预演结果与真实运行必然漂移,预演也就失去意义。
|
|
|
+///
|
|
|
+/// <b>写入边界(本服务的全部价值都建立在这条上)</b>:
|
|
|
+/// · 不写 ado_s8_detection_log
|
|
|
+/// · 不写 ado_s8_exception / timeline / notification_log
|
|
|
+/// · 不写 ado_s8_rule_detection_state(不推进抗抖计数)
|
|
|
+/// · 不改规则自身的任何列(不动 enabled / next_run_at / last_run_at / lock_*)
|
|
|
+/// · 不发通知
|
|
|
+/// 只读既有 exception 用于回答「这条命中现在是否已有活动异常」。
|
|
|
+///
|
|
|
+/// <b>enabled=false 的规则同样可预演</b>——这正是本服务存在的理由:先看清楚再决定启不启用。
|
|
|
+/// </summary>
|
|
|
+public class S8WatchRulePreviewService : ITransient
|
|
|
+{
|
|
|
+ private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
|
|
|
+ private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
|
|
|
+ private readonly S8MonitoringDataGateway _gateway;
|
|
|
+ private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
|
|
|
+ private readonly S8ShortageRuleEvaluator _shortageEvaluator;
|
|
|
+ private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
|
|
|
+
|
|
|
+ public S8WatchRulePreviewService(
|
|
|
+ SqlSugarRepository<AdoS8WatchRule> ruleRep,
|
|
|
+ SqlSugarRepository<AdoS8Exception> exceptionRep,
|
|
|
+ S8MonitoringDataGateway gateway,
|
|
|
+ S8TimeoutRuleEvaluator timeoutEvaluator,
|
|
|
+ S8ShortageRuleEvaluator shortageEvaluator,
|
|
|
+ S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
|
|
|
+ {
|
|
|
+ _ruleRep = ruleRep;
|
|
|
+ _exceptionRep = exceptionRep;
|
|
|
+ _gateway = gateway;
|
|
|
+ _timeoutEvaluator = timeoutEvaluator;
|
|
|
+ _shortageEvaluator = shortageEvaluator;
|
|
|
+ _outOfRangeEvaluator = outOfRangeEvaluator;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 预演一条规则。<paramref name="scope"/> 由 Controller 用服务端可信作用域盖章,
|
|
|
+ /// 规则按 (id, tenant, factory) 绑行 —— 越权 id 一律按不存在处理,不泄露他租户规则是否存在。
|
|
|
+ /// </summary>
|
|
|
+ public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
|
|
|
+ {
|
|
|
+ var rule = await _ruleRep.AsQueryable()
|
|
|
+ .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
|
|
|
+ .FirstAsync() ?? throw new S8NotFoundException();
|
|
|
+
|
|
|
+ var ruleType = rule.RuleType;
|
|
|
+ if (string.IsNullOrWhiteSpace(ruleType))
|
|
|
+ throw new S8BizException("规则未配置 rule_type,无法预演");
|
|
|
+
|
|
|
+ IS8RuleEvaluator evaluator = ruleType switch
|
|
|
+ {
|
|
|
+ S8TimeoutRuleEvaluator.RuleTypeCode => _timeoutEvaluator,
|
|
|
+ S8ShortageRuleEvaluator.RuleTypeCode => _shortageEvaluator,
|
|
|
+ S8OutOfRangeRuleEvaluator.RuleTypeCode => _outOfRangeEvaluator,
|
|
|
+ _ => throw new S8BizException($"不支持的规则类型:{ruleType}")
|
|
|
+ };
|
|
|
+
|
|
|
+ // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
|
|
|
+ // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
|
|
|
+ var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
|
|
|
+ var data = await _gateway.LoadAsync(scope.TenantId, scope.FactoryId, rule, ruleType, maxRows);
|
|
|
+ var candidateRows = data.RowSet.Rows.Count;
|
|
|
+
|
|
|
+ var hits = await evaluator.EvaluateAsync(scope.TenantId, scope.FactoryId, rule);
|
|
|
+
|
|
|
+ // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
|
|
|
+ // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
|
|
|
+ var dedupKeys = hits.Select(h => h.DedupKey).Where(k => !string.IsNullOrWhiteSpace(k)).Distinct().ToList();
|
|
|
+ var activeByDedupKey = dedupKeys.Count == 0
|
|
|
+ ? new Dictionary<string, long>(StringComparer.Ordinal)
|
|
|
+ : (await _exceptionRep.AsQueryable()
|
|
|
+ .Where(x => x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId
|
|
|
+ && !x.IsDeleted && x.Status != "CLOSED"
|
|
|
+ && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
|
|
|
+ .Select(x => new { x.DedupKey, x.Id })
|
|
|
+ .ToListAsync())
|
|
|
+ .GroupBy(x => x.DedupKey!, StringComparer.Ordinal)
|
|
|
+ .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
|
|
|
+
|
|
|
+ return new S8WatchRulePreviewResult
|
|
|
+ {
|
|
|
+ RuleId = rule.Id,
|
|
|
+ RuleCode = rule.RuleCode,
|
|
|
+ RuleType = ruleType,
|
|
|
+ DatasetCode = rule.DatasetCode,
|
|
|
+ Enabled = rule.Enabled,
|
|
|
+ TenantId = scope.TenantId,
|
|
|
+ FactoryId = scope.FactoryId,
|
|
|
+ EvaluatedAt = DateTime.Now,
|
|
|
+ CandidateRows = candidateRows,
|
|
|
+ HitCount = hits.Count,
|
|
|
+ // 未命中数 = 候选 - 命中。刻意不细分「已完成」与「未到期」:
|
|
|
+ // 那需要 preview 自己复刻一遍 evaluator 的判定分支,一旦两边逻辑漂移,
|
|
|
+ // 预演就会给出与真实运行不同的解释。宁可少报,不可假报。
|
|
|
+ NoHitRows = Math.Max(0, candidateRows - hits.Count),
|
|
|
+ WouldCreateCount = hits.Count(h => !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
|
|
|
+ WouldMatchExistingCount = hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
|
|
|
+ Hits = hits.Select(h => new S8WatchRulePreviewHit
|
|
|
+ {
|
|
|
+ SourceObjectType = h.SourceObjectType,
|
|
|
+ SourceObjectId = h.SourceObjectId,
|
|
|
+ RelatedObjectCode = h.RelatedObjectCode,
|
|
|
+ RelatedObjectName = h.Title,
|
|
|
+ ExceptionTypeCode = h.ExceptionTypeCode,
|
|
|
+ Severity = h.Severity,
|
|
|
+ DedupKey = h.DedupKey,
|
|
|
+ DetectedAt = h.DetectedAt,
|
|
|
+ ExistingActiveExceptionId =
|
|
|
+ activeByDedupKey.TryGetValue(h.DedupKey ?? string.Empty, out var exId) ? exId : null
|
|
|
+ }).ToList()
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// <summary>预演结果。纯读,不代表任何已发生的副作用。</summary>
|
|
|
+public sealed class S8WatchRulePreviewResult
|
|
|
+{
|
|
|
+ public long RuleId { get; init; }
|
|
|
+ public string RuleCode { get; init; } = string.Empty;
|
|
|
+ public string RuleType { get; init; } = string.Empty;
|
|
|
+ public string? DatasetCode { get; init; }
|
|
|
+
|
|
|
+ /// <summary>规则当前是否启用。预演**不受此值影响**,也不修改它。</summary>
|
|
|
+ public bool Enabled { get; init; }
|
|
|
+
|
|
|
+ public long TenantId { get; init; }
|
|
|
+ public long FactoryId { get; init; }
|
|
|
+ public DateTime EvaluatedAt { get; init; }
|
|
|
+
|
|
|
+ /// <summary>Provider 返回的候选行数(判定之前)。</summary>
|
|
|
+ public int CandidateRows { get; init; }
|
|
|
+
|
|
|
+ public int HitCount { get; init; }
|
|
|
+ public int NoHitRows { get; init; }
|
|
|
+
|
|
|
+ /// <summary>若此刻真的执行,会新建的异常数。</summary>
|
|
|
+ public int WouldCreateCount { get; init; }
|
|
|
+
|
|
|
+ /// <summary>若此刻真的执行,会命中既有活动异常(即刷新而非新建)的数量。</summary>
|
|
|
+ public int WouldMatchExistingCount { get; init; }
|
|
|
+
|
|
|
+ public List<S8WatchRulePreviewHit> Hits { get; init; } = new();
|
|
|
+}
|
|
|
+
|
|
|
+/// <summary>单条预演命中。</summary>
|
|
|
+public sealed class S8WatchRulePreviewHit
|
|
|
+{
|
|
|
+ public string SourceObjectType { get; init; } = string.Empty;
|
|
|
+ public string SourceObjectId { get; init; } = string.Empty;
|
|
|
+ public string RelatedObjectCode { get; init; } = string.Empty;
|
|
|
+ public string RelatedObjectName { get; init; } = string.Empty;
|
|
|
+ public string ExceptionTypeCode { get; init; } = string.Empty;
|
|
|
+ public string Severity { get; init; } = string.Empty;
|
|
|
+ public string DedupKey { get; init; } = string.Empty;
|
|
|
+ public DateTime DetectedAt { get; init; }
|
|
|
+
|
|
|
+ /// <summary>非空表示该命中已有活动异常,真实执行时是刷新而不是新建。</summary>
|
|
|
+ public long? ExistingActiveExceptionId { get; init; }
|
|
|
+}
|