S8TimeoutRuleEvaluator.cs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  7. /// <summary>
  8. /// R2 TIMEOUT 类规则 evaluator MVP。
  9. /// params_json 约定(首版最小集合):
  10. /// { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode }
  11. /// 判定:dueAt &lt;= now - graceMinutes 且 status 不在 completedStates 内 → HIT。
  12. /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
  13. /// </summary>
  14. public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
  15. {
  16. public const string RuleTypeCode = "TIMEOUT";
  17. public string RuleType => RuleTypeCode;
  18. private const string SqlDataSourceType = "SQL";
  19. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  20. public S8TimeoutRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
  21. {
  22. _dataSourceRep = dataSourceRep;
  23. }
  24. public async Task<List<S8RuleHit>> EvaluateAsync(
  25. long tenantId,
  26. long factoryId,
  27. AdoS8WatchRule rule,
  28. IReadOnlyList<AdoS8AlertRule> alertRules,
  29. CancellationToken cancellationToken = default)
  30. {
  31. var hits = new List<S8RuleHit>();
  32. // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException,
  33. // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。
  34. if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
  35. throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 expression 或 params_json");
  36. S8TimeoutParams parameters;
  37. try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); }
  38. catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"TIMEOUT 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
  39. if (string.IsNullOrWhiteSpace(parameters.DueAtField)
  40. || string.IsNullOrWhiteSpace(parameters.StatusField)
  41. || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
  42. throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode");
  43. var dataSource = await _dataSourceRep.AsQueryable()
  44. .Where(x => x.Id == rule.DataSourceId
  45. && x.TenantId == tenantId
  46. && x.FactoryId == factoryId
  47. && x.Enabled)
  48. .FirstAsync();
  49. if (dataSource == null
  50. || string.IsNullOrWhiteSpace(dataSource.Endpoint)
  51. || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
  52. throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
  53. DataTable table;
  54. try
  55. {
  56. using var db = CreateSqlScope(dataSource.Endpoint!);
  57. table = await db.Ado.GetDataTableAsync(rule.Expression!);
  58. }
  59. catch (Exception ex)
  60. {
  61. throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
  62. }
  63. var detectedAt = DateTime.Now;
  64. var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes);
  65. var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
  66. ? rule.WatchObjectType
  67. : rule.SourceObjectType!;
  68. foreach (DataRow row in table.Rows)
  69. {
  70. var status = ReadString(row, parameters.StatusField!) ?? string.Empty;
  71. if (parameters.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase))
  72. continue;
  73. var due = ReadDateTime(row, parameters.DueAtField!);
  74. if (due == null || due > threshold) continue;
  75. var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField)
  76. ? "related_object_code"
  77. : parameters.ObjectCodeField!;
  78. var relatedObjectCode = ReadString(row, objectCodeField) ?? string.Empty;
  79. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  80. var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField)
  81. ? relatedObjectCode
  82. : ReadString(row, parameters.ObjectIdField!) ?? relatedObjectCode;
  83. var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
  84. hits.Add(new S8RuleHit
  85. {
  86. SourceRuleId = rule.Id,
  87. SourceRuleCode = rule.RuleCode,
  88. SourceObjectType = sourceObjectType,
  89. SourceObjectId = sourceObjectId,
  90. RelatedObjectCode = relatedObjectCode,
  91. ExceptionTypeCode = parameters.ExceptionTypeCode!,
  92. SceneCode = rule.SceneCode,
  93. Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
  94. DedupKey = dedupKey,
  95. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters),
  96. DetectedAt = detectedAt,
  97. Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
  98. DataSourceId = dataSource.Id,
  99. OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
  100. ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
  101. });
  102. }
  103. return hits;
  104. }
  105. private SqlSugarScope CreateSqlScope(string connectionString)
  106. {
  107. var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
  108. return new SqlSugarScope(new ConnectionConfig
  109. {
  110. ConfigId = $"s8-timeout-eval-{Guid.NewGuid():N}",
  111. DbType = dbType,
  112. ConnectionString = connectionString,
  113. InitKeyType = InitKeyType.Attribute,
  114. IsAutoCloseConnection = true
  115. });
  116. }
  117. /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
  118. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  119. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  120. private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, S8TimeoutParams parameters)
  121. {
  122. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  123. foreach (DataColumn column in row.Table.Columns)
  124. {
  125. var value = row[column];
  126. payload[column.ColumnName] = value == DBNull.Value ? null : value;
  127. }
  128. payload["__ruleType"] = RuleTypeCode;
  129. payload["__sourceObjectType"] = sourceObjectType;
  130. payload["__sourceObjectId"] = sourceObjectId;
  131. payload["__dueAt"] = dueAt;
  132. payload["__status"] = status;
  133. payload["__graceMinutes"] = parameters.GraceMinutes;
  134. payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
  135. return JsonSerializer.Serialize(payload);
  136. }
  137. private static string? ReadString(DataRow row, string columnName)
  138. {
  139. if (string.IsNullOrWhiteSpace(columnName)) return null;
  140. if (!row.Table.Columns.Contains(columnName)) return null;
  141. var v = row[columnName];
  142. return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
  143. }
  144. private static DateTime? ReadDateTime(DataRow row, string columnName)
  145. {
  146. if (!row.Table.Columns.Contains(columnName)) return null;
  147. var v = row[columnName];
  148. if (v == DBNull.Value) return null;
  149. if (v is DateTime dt) return dt;
  150. return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var p) ? p : null;
  151. }
  152. private static long? ReadLong(DataRow row, string columnName)
  153. {
  154. if (!row.Table.Columns.Contains(columnName)) return null;
  155. var v = row[columnName];
  156. if (v == DBNull.Value) return null;
  157. return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
  158. }
  159. }