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;
}
}
}