using System.Data; using System.Globalization; using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity.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 NOT IN 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(); if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson)) return hits; TimeoutParams parameters; try { parameters = TimeoutParams.Parse(rule.ParamsJson!); } catch { return hits; } if (string.IsNullOrWhiteSpace(parameters.DueAtField) || string.IsNullOrWhiteSpace(parameters.StatusField) || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)) return hits; 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)) return hits; DataTable table; try { using var db = CreateSqlScope(dataSource.Endpoint!); table = await db.Ado.GetDataTableAsync(rule.Expression!); } catch { return hits; } 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 = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity, DedupKey = dedupKey, SourcePayload = BuildPayload(row, 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 }); } private 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, DateTime dueAt, string status, TimeoutParams 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["__dueAt"] = dueAt; payload["__status"] = status; payload["__graceMinutes"] = parameters.GraceMinutes; payload["__sourceObjectType"] = parameters.ObjectIdField; 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; } private sealed class TimeoutParams { public string? DueAtField { get; set; } public string? StatusField { get; set; } public List CompletedStates { get; set; } = new(); public string? ObjectCodeField { get; set; } public string? ObjectIdField { get; set; } public int GraceMinutes { get; set; } public string? ExceptionTypeCode { get; set; } public static TimeoutParams Parse(string json) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var p = new TimeoutParams(); if (root.TryGetProperty("dueAtField", out var v1)) p.DueAtField = v1.GetString(); if (root.TryGetProperty("statusField", out var v2)) p.StatusField = v2.GetString(); if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString(); if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString(); if (root.TryGetProperty("graceMinutes", out var v5) && v5.ValueKind == JsonValueKind.Number) p.GraceMinutes = v5.GetInt32(); if (root.TryGetProperty("exceptionTypeCode", out var v6)) p.ExceptionTypeCode = v6.GetString(); if (root.TryGetProperty("completedStates", out var v7) && v7.ValueKind == JsonValueKind.Array) { foreach (var s in v7.EnumerateArray()) { var str = s.GetString(); if (!string.IsNullOrWhiteSpace(str)) p.CompletedStates.Add(str!); } } return p; } } }