using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules; /// /// TIMEOUT 类规则 evaluator。 /// 判定:dueAt <= now - graceMinutes 且 status 不在 completedStates 内 → HIT。 /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。 /// /// S8-DATASET-PROVIDER-FOUNDATION-1:取数由 承担, /// 本类不直接依赖 DataTable / 数据源 / SQL。 /// /// S8-RULE-GOVERNANCE-BATCH1:判定语义改由代码定义供给。 /// 此前 dueAtField / statusField / completedStates / objectIdField / /// exceptionTypeCode 全部从 params_json 解析 —— 而 params_json 是配置页可整块 /// 覆写的自由文本。于是「这条规则判什么」实际由业务用户决定,改一个字符串就换一套业务含义。 /// 现在这些一律取自 供给), /// params_json 只剩 graceMinutes 等真正的运行策略。 /// /// 连带修复:params_json 为 NULL 不再抛 rule_not_configured —— 定义在代码里, /// 一条没有运行参数的规则完全可以按默认值跑。 /// public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient { public const string RuleTypeCode = "TIMEOUT"; public string RuleType => RuleTypeCode; private readonly S8MonitoringDataGateway _dataGateway; private readonly IS8RuleCatalog _ruleCatalog; private readonly ILogger _logger; public S8TimeoutRuleEvaluator( S8MonitoringDataGateway dataGateway, IS8RuleCatalog ruleCatalog, ILogger logger) { _dataGateway = dataGateway; _ruleCatalog = ruleCatalog; _logger = logger; } public async Task> EvaluateAsync( long tenantId, AdoS8WatchRule rule, CancellationToken cancellationToken = default) { // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException, // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。 // // S8-RULE-GOVERNANCE-BATCH1:第一道门是「这条规则在当前代码版本里有没有定义」。 // 没有定义 = 它的判定语义无处可取,绝不允许猜(历史实现会去 params_json 里猜, // 于是任何人建一条规则、写一段 JSON 就能让调度器替他跑)。 var effective = ResolveEffective(rule); // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。 // S8-STANDARD-DATASET-HARD-CUTOVER-1:不再解析 commandTimeout —— S8 已不执行 SQL, // 超时归 Provider 自己的取数实现负责。 var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger); var data = await _dataGateway.LoadAsync( tenantId, rule, RuleTypeCode, maxRows, cancellationToken); return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now); } /// /// 解析生效规则:定义必须存在、类型必须匹配、TIMEOUT 语义必须齐备。 /// 三者任一不满足都是**发布态问题**,不是数据问题,故 fail-fast 而非静默返空命中。 /// private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule) { var definition = _ruleCatalog.TryGet(rule.RuleCode) ?? throw new S8RuleEvaluatorException( S8RuleCatalog.ReasonNotFound, $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行"); if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal)) throw new S8RuleEvaluatorException( "rule_type_mismatch", $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 TIMEOUT evaluator 执行"); if (definition.Timeout == null) throw new S8RuleEvaluatorException( "rule_semantics_missing", $"规则 {rule.RuleCode} 的代码定义缺少 TIMEOUT 判定语义"); return S8EffectiveRule.Resolve(rule, definition); } /// /// TIMEOUT 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。 /// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。 /// internal static List EvaluateRows( S8MonitoringRowSet rowSet, S8EffectiveRule effective, long tenantId, DateTime detectedAt) { var definition = effective.Definition; var semantics = definition.Timeout!; var parameters = effective.Parameters; var rule = effective.Row; var hits = new List(); var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes); // 源对象类型来自定义。此前是 rule.SourceObjectType ?? rule.WatchObjectType 两列回落 —— // 两列都是用户可写的,而该值直接进 dedup_key,改动即造成历史异常断代。 var sourceObjectType = definition.SourceObjectType; // 判定列来自定义。Provider 按 canonical 名产出行,因此这些值就是 canonical 名; // 若结果集确实没有该列,读取自然得到 null → 该行不命中,不再退回"某个 JSON 里写的列名"。 var statusColumn = semantics.StatusColumn; var dueAtColumn = semantics.DueAtColumn; var objectCodeColumn = semantics.RelatedObjectCodeColumn; var objectIdColumn = semantics.SourceObjectIdColumn; foreach (var row in rowSet.Rows) { var status = row.GetString(statusColumn) ?? string.Empty; if (semantics.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase)) continue; var due = row.GetDateTime(dueAtColumn); if (due == null || due > threshold) continue; var relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty; if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue; var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode; var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId); hits.Add(new S8RuleHit { SourceRuleId = rule.Id, SourceRuleCode = definition.RuleCode, SourceObjectType = sourceObjectType, SourceObjectId = sourceObjectId, RelatedObjectCode = relatedObjectCode, ExceptionTypeCode = definition.ExceptionTypeCode, SceneCode = definition.SceneCode, Severity = parameters.Severity, DedupKey = dedupKey, SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, definition, parameters), DetectedAt = detectedAt, Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})", OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId, ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId }); } return hits; } /// /// 构造 dedup_key:T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。 /// /// S8-TENANT-ONLY-BATCH5:去掉了 :F{factory} 段。 /// 租户隔离由首段 T{tenant} 与各查询的 tenant_id 谓词双重保证; /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。 /// /// 真库证据(迁移前取证):dwd_supplier_delivery 全表 39854 行 / 4 租户 / 97 个快照, /// (tenant_id, stat_date, po_no, po_line) 重复组为 0,跨工厂碰撞组为 0。 /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的 /// SourceObjectId contract 负责(例如把工厂编号并进对象标识), /// 而不是把工厂重新变回隔离维度。 /// /// internal 暴露供测试。 /// internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) => $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}"; private static string BuildPayload( S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, Definitions.S8RuleDefinition definition, S8RuleRuntimeParameters parameters) { var payload = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in row.Values) payload[kv.Key] = kv.Value; payload["__ruleType"] = RuleTypeCode; payload["__sourceObjectType"] = sourceObjectType; payload["__sourceObjectId"] = sourceObjectId; payload["__dueAt"] = dueAt; payload["__status"] = status; payload["__graceMinutes"] = parameters.GraceMinutes; payload["__exceptionTypeCode"] = definition.ExceptionTypeCode; return JsonSerializer.Serialize(payload); } }