S8TimeoutRuleEvaluator.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Microsoft.Extensions.Logging;
  7. using SqlSugar;
  8. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  9. /// <summary>
  10. /// R2 TIMEOUT 类规则 evaluator MVP。
  11. /// params_json 约定(首版最小集合):
  12. /// { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode }
  13. /// 判定:dueAt &lt;= now - graceMinutes 且 status 不在 completedStates 内 → HIT。
  14. /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
  15. /// </summary>
  16. public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
  17. {
  18. public const string RuleTypeCode = "TIMEOUT";
  19. public string RuleType => RuleTypeCode;
  20. private const string SqlDataSourceType = "SQL";
  21. // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-FIX-1:S8ConfigDraftService.BuildExpression 统一把结果列
  22. // 别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical 读取,仅当结果集
  23. // 不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史未别名规则)。
  24. private const string CanonicalDueAtColumn = "due_at";
  25. private const string CanonicalStatusColumn = "status";
  26. private const string CanonicalSourceObjectIdColumn = "source_object_id";
  27. private const string CanonicalRelatedObjectCodeColumn = "related_object_code";
  28. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  29. private readonly S8SqlSugarScopeFactory _scopeFactory;
  30. private readonly ILogger<S8TimeoutRuleEvaluator> _logger;
  31. public S8TimeoutRuleEvaluator(
  32. SqlSugarRepository<AdoS8DataSource> dataSourceRep,
  33. S8SqlSugarScopeFactory scopeFactory,
  34. ILogger<S8TimeoutRuleEvaluator> logger)
  35. {
  36. _dataSourceRep = dataSourceRep;
  37. _scopeFactory = scopeFactory;
  38. _logger = logger;
  39. }
  40. public async Task<List<S8RuleHit>> EvaluateAsync(
  41. long tenantId,
  42. long factoryId,
  43. AdoS8WatchRule rule,
  44. IReadOnlyList<AdoS8AlertRule> alertRules,
  45. CancellationToken cancellationToken = default)
  46. {
  47. var hits = new List<S8RuleHit>();
  48. // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
  49. // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。
  50. if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
  51. throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 expression 或 params_json");
  52. S8TimeoutParams parameters;
  53. try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); }
  54. catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"TIMEOUT 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
  55. if (string.IsNullOrWhiteSpace(parameters.DueAtField)
  56. || string.IsNullOrWhiteSpace(parameters.StatusField)
  57. || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
  58. throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode");
  59. var dataSource = await _dataSourceRep.AsQueryable()
  60. .Where(x => x.Id == rule.DataSourceId
  61. && x.TenantId == tenantId
  62. && x.FactoryId == factoryId
  63. && x.Enabled)
  64. .FirstAsync();
  65. if (dataSource == null
  66. || string.IsNullOrWhiteSpace(dataSource.Endpoint)
  67. || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
  68. throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
  69. // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
  70. var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
  71. var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
  72. DataTable table;
  73. try
  74. {
  75. using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, _dataSourceRep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
  76. table = await db.Ado.GetDataTableAsync(rule.Expression!);
  77. }
  78. catch (Exception ex)
  79. {
  80. throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
  81. }
  82. // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。
  83. // 必须置于 try-catch 之外,避免被 query_failed 误捕获再包装。
  84. S8EvaluatorGuard.EnsureRowCountWithinLimit(table.Rows.Count, maxRows, RuleTypeCode, rule.RuleCode);
  85. var detectedAt = DateTime.Now;
  86. var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes);
  87. var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
  88. ? rule.WatchObjectType
  89. : rule.SourceObjectType!;
  90. // 结果列名一次性解析(结果集列在整张 DataTable 内稳定):canonical 优先,缺失回退 params 真实列名。
  91. var statusColumn = ResolveResultColumn(table, CanonicalStatusColumn, parameters.StatusField);
  92. var dueAtColumn = ResolveResultColumn(table, CanonicalDueAtColumn, parameters.DueAtField);
  93. var objectCodeColumn = ResolveResultColumn(table, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
  94. var objectIdColumn = ResolveResultColumn(table, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
  95. foreach (DataRow row in table.Rows)
  96. {
  97. var status = ReadString(row, statusColumn) ?? string.Empty;
  98. if (parameters.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase))
  99. continue;
  100. var due = ReadDateTime(row, dueAtColumn);
  101. if (due == null || due > threshold) continue;
  102. var relatedObjectCode = ReadString(row, objectCodeColumn) ?? string.Empty;
  103. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  104. var sourceObjectId = ReadString(row, objectIdColumn) ?? relatedObjectCode;
  105. var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
  106. hits.Add(new S8RuleHit
  107. {
  108. SourceRuleId = rule.Id,
  109. SourceRuleCode = rule.RuleCode,
  110. SourceObjectType = sourceObjectType,
  111. SourceObjectId = sourceObjectId,
  112. RelatedObjectCode = relatedObjectCode,
  113. ExceptionTypeCode = parameters.ExceptionTypeCode!,
  114. SceneCode = rule.SceneCode,
  115. Severity = S8SeverityCode.Normalize(rule.Severity),
  116. DedupKey = dedupKey,
  117. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters),
  118. DetectedAt = detectedAt,
  119. Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
  120. DataSourceId = dataSource.Id,
  121. OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
  122. ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
  123. });
  124. }
  125. return hits;
  126. }
  127. /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
  128. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  129. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  130. private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, S8TimeoutParams parameters)
  131. {
  132. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  133. foreach (DataColumn column in row.Table.Columns)
  134. {
  135. var value = row[column];
  136. payload[column.ColumnName] = value == DBNull.Value ? null : value;
  137. }
  138. payload["__ruleType"] = RuleTypeCode;
  139. payload["__sourceObjectType"] = sourceObjectType;
  140. payload["__sourceObjectId"] = sourceObjectId;
  141. payload["__dueAt"] = dueAt;
  142. payload["__status"] = status;
  143. payload["__graceMinutes"] = parameters.GraceMinutes;
  144. payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
  145. return JsonSerializer.Serialize(payload);
  146. }
  147. /// <summary>
  148. /// 结果列名解析:BuildExpression 已把结果列统一别名为 canonical(due_at/status/source_object_id/
  149. /// related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,回退到 params_json
  150. /// 指定的真实列名(兼容历史未别名规则)。仅在 canonical 与 params 字段之间二选一,不新增无依据兜底字段。
  151. /// </summary>
  152. private static string ResolveResultColumn(DataTable table, string canonicalColumn, string? paramsColumn)
  153. {
  154. if (table.Columns.Contains(canonicalColumn)) return canonicalColumn;
  155. return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
  156. }
  157. private static string? ReadString(DataRow row, string columnName)
  158. {
  159. if (string.IsNullOrWhiteSpace(columnName)) return null;
  160. if (!row.Table.Columns.Contains(columnName)) return null;
  161. var v = row[columnName];
  162. return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
  163. }
  164. private static DateTime? ReadDateTime(DataRow row, string columnName)
  165. {
  166. if (!row.Table.Columns.Contains(columnName)) return null;
  167. var v = row[columnName];
  168. if (v == DBNull.Value) return null;
  169. if (v is DateTime dt) return dt;
  170. return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var p) ? p : null;
  171. }
  172. private static long? ReadLong(DataRow row, string columnName)
  173. {
  174. if (!row.Table.Columns.Contains(columnName)) return null;
  175. var v = row[columnName];
  176. if (v == DBNull.Value) return null;
  177. return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
  178. }
  179. }