using System.Data; using System.Globalization; using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules; /// /// R2 TIMEOUT 类规则 evaluator MVP。 /// params_json 约定(首版最小集合): /// { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode } /// 判定:dueAt <= now - graceMinutes 且 status 不在 completedStates 内 → HIT。 /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。 /// public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient { public const string RuleTypeCode = "TIMEOUT"; public string RuleType => RuleTypeCode; private const string SqlDataSourceType = "SQL"; private readonly SqlSugarRepository _dataSourceRep; public S8TimeoutRuleEvaluator(SqlSugarRepository dataSourceRep) { _dataSourceRep = dataSourceRep; } public async Task> EvaluateAsync( long tenantId, long factoryId, AdoS8WatchRule rule, IReadOnlyList alertRules, CancellationToken cancellationToken = default) { var hits = new List(); // R5 evaluator 失败语义保护:所有"非命中判定"路径均改为抛出 S8RuleEvaluatorException, // 由 SchedulerService 标记 evaluate_failed 并跳过 recovery reconcile,避免对未确认未命中的 rule 误标 recovered_at。 if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson)) throw new S8RuleEvaluatorException("rule_not_configured", $"TIMEOUT 规则 {rule.RuleCode} 缺少 expression 或 params_json"); S8TimeoutParams parameters; try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); } 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)) throw new S8RuleEvaluatorException("params_schema_invalid", $"TIMEOUT 规则 {rule.RuleCode} params 缺少必填字段 dueAtField/statusField/exceptionTypeCode"); var dataSource = await _dataSourceRep.AsQueryable() .Where(x => x.Id == rule.DataSourceId && x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled) .FirstAsync(); if (dataSource == null || string.IsNullOrWhiteSpace(dataSource.Endpoint) || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase)) throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})"); DataTable table; try { using var db = CreateSqlScope(dataSource.Endpoint!); table = await db.Ado.GetDataTableAsync(rule.Expression!); } catch (Exception ex) { throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex); } var detectedAt = DateTime.Now; var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes); var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType) ? rule.WatchObjectType : rule.SourceObjectType!; foreach (DataRow row in table.Rows) { var status = ReadString(row, parameters.StatusField!) ?? string.Empty; if (parameters.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase)) continue; var due = ReadDateTime(row, parameters.DueAtField!); if (due == null || due > threshold) continue; var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField) ? "related_object_code" : parameters.ObjectCodeField!; var relatedObjectCode = ReadString(row, objectCodeField) ?? string.Empty; if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue; var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField) ? relatedObjectCode : ReadString(row, parameters.ObjectIdField!) ?? relatedObjectCode; var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId); hits.Add(new S8RuleHit { SourceRuleId = rule.Id, SourceRuleCode = rule.RuleCode, SourceObjectType = sourceObjectType, SourceObjectId = sourceObjectId, RelatedObjectCode = relatedObjectCode, ExceptionTypeCode = parameters.ExceptionTypeCode!, SceneCode = rule.SceneCode, Severity = S8SeverityCode.Normalize(rule.Severity), DedupKey = dedupKey, SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters), DetectedAt = detectedAt, Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})", DataSourceId = dataSource.Id, OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"), ResponsibleDeptId = ReadLong(row, "responsible_dept_id") }); } return hits; } private SqlSugarScope CreateSqlScope(string connectionString) { var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType; return new SqlSugarScope(new ConnectionConfig { ConfigId = $"s8-timeout-eval-{Guid.NewGuid():N}", DbType = dbType, ConnectionString = connectionString, InitKeyType = InitKeyType.Attribute, IsAutoCloseConnection = true }); } /// 构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。 internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) => $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}"; private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, S8TimeoutParams parameters) { var payload = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (DataColumn column in row.Table.Columns) { var value = row[column]; payload[column.ColumnName] = value == DBNull.Value ? null : value; } payload["__ruleType"] = RuleTypeCode; payload["__sourceObjectType"] = sourceObjectType; payload["__sourceObjectId"] = sourceObjectId; payload["__dueAt"] = dueAt; payload["__status"] = status; payload["__graceMinutes"] = parameters.GraceMinutes; payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode; return JsonSerializer.Serialize(payload); } private static string? ReadString(DataRow row, string columnName) { if (string.IsNullOrWhiteSpace(columnName)) return null; if (!row.Table.Columns.Contains(columnName)) return null; var v = row[columnName]; return v == DBNull.Value ? null : Convert.ToString(v)?.Trim(); } private static DateTime? ReadDateTime(DataRow row, string columnName) { if (!row.Table.Columns.Contains(columnName)) return null; var v = row[columnName]; if (v == DBNull.Value) return null; if (v is DateTime dt) return dt; return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var p) ? p : null; } private static long? ReadLong(DataRow row, string columnName) { if (!row.Table.Columns.Contains(columnName)) return null; var v = row[columnName]; if (v == DBNull.Value) return null; return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null; } }