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