浏览代码

feat(s8): mark recovered time for inactive rule hits

YY968XX 3 月之前
父节点
当前提交
1f7e0f6b31

+ 7 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -39,15 +39,16 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     {
         var hits = new List<S8RuleHit>();
 
+        // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
         if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            return hits;
+            throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
 
         S8OutOfRangeParams parameters;
         try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
-        catch { return hits; }
+        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
 
         if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
-            return hits;
+            throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
 
         var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
             ? DefaultExceptionTypeCode
@@ -62,7 +63,7 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
-            return hits;
+            throw new S8RuleEvaluatorException("data_source_unavailable", $"OUT_OF_RANGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         DataTable table;
         try
@@ -70,9 +71,9 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             using var db = CreateSqlScope(dataSource.Endpoint!);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
-        catch
+        catch (Exception ex)
         {
-            return hits;
+            throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
         var detectedAt = DateTime.Now;

+ 20 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleEvaluatorException.cs

@@ -0,0 +1,20 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R5 evaluator 失败语义保护:evaluator 在 params/数据源/表达式执行任意环节失败时抛此异常,
+/// 由 S8WatchSchedulerService 捕获并标记 evaluate_failed,避免误进入 recovery reconcile 路径。
+/// </summary>
+public sealed class S8RuleEvaluatorException : Exception
+{
+    public string Reason { get; }
+
+    public S8RuleEvaluatorException(string reason, string message) : base(message)
+    {
+        Reason = reason;
+    }
+
+    public S8RuleEvaluatorException(string reason, string message, Exception inner) : base(message, inner)
+    {
+        Reason = reason;
+    }
+}

+ 7 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -41,17 +41,18 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
     {
         var hits = new List<S8RuleHit>();
 
+        // R5 evaluator 失败语义保护:与 TIMEOUT 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
         if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            return hits;
+            throw new S8RuleEvaluatorException("rule_not_configured", $"SHORTAGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
 
         S8ShortageParams parameters;
         try { parameters = S8ShortageParams.Parse(rule.ParamsJson!); }
-        catch { return hits; }
+        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"SHORTAGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
 
         if (string.IsNullOrWhiteSpace(parameters.TargetQtyField)
             || string.IsNullOrWhiteSpace(parameters.ActualQtyField)
             || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
-            return hits;
+            throw new S8RuleEvaluatorException("params_schema_invalid", $"SHORTAGE 规则 {rule.RuleCode} params 缺少必填字段 targetQtyField/actualQtyField/exceptionTypeCode");
 
         var dataSource = await _dataSourceRep.AsQueryable()
             .Where(x => x.Id == rule.DataSourceId
@@ -62,7 +63,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
-            return hits;
+            throw new S8RuleEvaluatorException("data_source_unavailable", $"SHORTAGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         DataTable table;
         try
@@ -70,9 +71,9 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             using var db = CreateSqlScope(dataSource.Endpoint!);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
-        catch
+        catch (Exception ex)
         {
-            return hits;
+            throw new S8RuleEvaluatorException("query_failed", $"SHORTAGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
         var detectedAt = DateTime.Now;

+ 8 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -36,17 +36,19 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     {
         var hits = new List<S8RuleHit>();
 
+        // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
+        // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。
         if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
-            return hits;
+            throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 expression 或 params_json");
 
         S8TimeoutParams parameters;
         try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); }
-        catch { return hits; }
+        catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"TIMEOUT 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
 
         if (string.IsNullOrWhiteSpace(parameters.DueAtField)
             || string.IsNullOrWhiteSpace(parameters.StatusField)
             || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
-            return hits;
+            throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode");
 
         var dataSource = await _dataSourceRep.AsQueryable()
             .Where(x => x.Id == rule.DataSourceId
@@ -57,7 +59,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         if (dataSource == null
             || string.IsNullOrWhiteSpace(dataSource.Endpoint)
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
-            return hits;
+            throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
         DataTable table;
         try
@@ -65,9 +67,9 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             using var db = CreateSqlScope(dataSource.Endpoint!);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
-        catch
+        catch (Exception ex)
         {
-            return hits;
+            throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
         var detectedAt = DateTime.Now;

+ 67 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 using System.Data;
 using System.Globalization;
@@ -25,6 +26,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
+    private readonly ILogger<S8WatchSchedulerService> _logger;
 
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string SqlDataSourceType = "SQL";
@@ -45,7 +47,8 @@ public class S8WatchSchedulerService : ITransient
         S8ManualReportService manualReportService,
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
-        S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
+        S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
+        ILogger<S8WatchSchedulerService> logger)
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
@@ -57,6 +60,7 @@ public class S8WatchSchedulerService : ITransient
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
         _outOfRangeEvaluator = outOfRangeEvaluator;
+        _logger = logger;
     }
 
     public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
@@ -454,6 +458,18 @@ public class S8WatchSchedulerService : ITransient
                 continue;
             }
 
+            // R5-RECOVERY-MINIMAL-1:evaluator 成功执行后调和 recovered_at。
+            // 仅在 evaluator 成功(throw 不会到这里)时才能判定"未命中"。仅写 recovered_at + updated_at,
+            // 绝不动 status / assignee / verifier / source_payload / last_detected_at。
+            try
+            {
+                await ReconcileRecoveriesForRuleAsync(tenantId, factoryId, rule, ruleType, hits);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "recovery_reconcile_failed ruleCode={RuleCode} ruleType={RuleType}", rule.RuleCode, ruleType);
+            }
+
             foreach (var hit in hits)
             {
                 if (string.IsNullOrWhiteSpace(hit.DedupKey))
@@ -609,6 +625,56 @@ public class S8WatchSchedulerService : ITransient
             .ExecuteCommandAsync();
     }
 
+    /// <summary>
+    /// R5 恢复时间最小闭环:对当前 rule 下未关闭、有 dedup_key、recovered_at 仍为 NULL 的异常,
+    /// 凡不在本轮 hits.dedup_key 集合内的,写入 recovered_at = now、updated_at = now。
+    /// 仅写这 2 列;不动 status / assignee / verifier / source_payload / last_detected_at;
+    /// recovered_at 一旦写入,本轮不做复发清空。
+    /// </summary>
+    private async Task ReconcileRecoveriesForRuleAsync(
+        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits)
+    {
+        var hitDedupKeys = hits
+            .Where(h => !string.IsNullOrWhiteSpace(h.DedupKey))
+            .Select(h => h.DedupKey)
+            .ToHashSet(StringComparer.Ordinal);
+
+        var candidates = await _exceptionRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && !x.IsDeleted
+                        && x.Status != "CLOSED"
+                        && x.SourceRuleCode == rule.RuleCode
+                        && x.DedupKey != null
+                        && x.RecoveredAt == null)
+            .Select(x => new { x.Id, x.DedupKey })
+            .ToListAsync();
+        if (candidates.Count == 0) return;
+
+        var now = DateTime.Now;
+        var recoveredIds = new List<long>();
+        foreach (var c in candidates)
+        {
+            if (hitDedupKeys.Contains(c.DedupKey!)) continue;
+            await _exceptionRep.Context.Updateable<AdoS8Exception>()
+                .SetColumns(x => new AdoS8Exception
+                {
+                    RecoveredAt = now,
+                    UpdatedAt = now
+                })
+                .Where(x => x.Id == c.Id)
+                .ExecuteCommandAsync();
+            recoveredIds.Add(c.Id);
+        }
+
+        if (recoveredIds.Count > 0)
+        {
+            _logger.LogInformation(
+                "rule_recovered ruleCode={RuleCode} ruleType={RuleType} recoveredCount={Count} recoveredIds={Ids}",
+                rule.RuleCode, ruleType, recoveredIds.Count, string.Join(",", recoveredIds));
+        }
+    }
+
     private async Task RefreshDetectionAsync(long exceptionId, S8RuleHit hit)
     {
         await _exceptionRep.Context.Updateable<AdoS8Exception>()