using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules; /// /// S8-RULE-GOVERNANCE-BATCH1:一条规则在某租户下的**运行参数**(B 类)。 /// /// 与 Definition(A 类)的分界线:改这里的值,规则「判定什么」不变,只是 /// 「多久跑一次、抖动几次算数、宽限多久、报多严重、兜底派给谁」变了。 /// 改 Definition 才会改变业务含义 —— 而 Definition 在代码里,没有 API 能改。 /// /// params_json 的新职责:只承载 / /// / 三项。 /// 其余参数是实体列(poll / trigger / recover / severity),不进 JSON。 /// /// 历史 JSON 兼容(本批不做 migration):库里现存的 params_json 可能是 /// 旧的 A+B 全量 JSON、可能只有 B、也可能是 NULL。三种都必须能起来: /// /// 旧 A 字段(dueAtField / statusField / completedStates / objectIdField / /// objectCodeField / exceptionTypeCode)→ 读到也忽略,绝不作为语义来源; /// B 字段存在 → 正常解析; /// params_json 为 NULL / 空白 / 非法 JSON → 全部回落 Definition 声明的默认值, /// 不再抛 rule_not_configured /// /// 最后一条是 G1 的直接修复点:本地 Rule 01 的 params_json 已被 /// PUT /params {"enabled":false} 抹成 NULL,在旧代码里这条规则已经永久跑不起来。 /// public sealed class S8RuleRuntimeParameters { /// 轮询间隔(秒)。真源是实体列 poll_interval_seconds public int PollIntervalSeconds { get; init; } /// 连续命中 N 次才建单。真源是实体列 trigger_count_required public int TriggerCountRequired { get; init; } /// 连续未命中 N 次才标 recovered。真源是实体列 recover_count_required public int RecoverCountRequired { get; init; } /// 严重度。真源是实体列 severity;非法值回落 Definition 默认,不静默降级为 FOLLOW。 public string Severity { get; init; } = S8SeverityCode.Follow; /// 宽限分钟。真源是 params_json。 public int GraceMinutes { get; init; } /// 发生部门兜底。真源是 params_json;未配置为 null。 public long? DefaultOccurrenceDeptId { get; init; } /// 责任部门兜底。真源是 params_json;未配置为 null。 public long? DefaultResponsibleDeptId { get; init; } // ───────────────────────────────────────────────────────────── // params_json 的 B 类键名。写入与读取共用同一批常量,杜绝两处拼写漂移。 // ───────────────────────────────────────────────────────────── internal const string KeyGraceMinutes = "graceMinutes"; internal const string KeyDefaultOccurrenceDeptId = "defaultOccurrenceDeptId"; internal const string KeyDefaultResponsibleDeptId = "defaultResponsibleDeptId"; /// /// 从实体行 + Definition 解析出生效运行参数。 /// 本方法不抛异常:任何解析失败都回落默认值。运行参数解析失败不应该让一条 /// 业务语义完全正确的规则停摆 —— 那正是 G1 造成的后果。 /// public static S8RuleRuntimeParameters Resolve(AdoS8WatchRule rule, S8RuleDefinition definition) { var policy = definition.Parameters ?? new S8RuleParameterPolicy(); var graceMinutes = policy.GraceMinutesDefault; long? occurrenceDeptId = null; long? responsibleDeptId = null; if (!string.IsNullOrWhiteSpace(rule.ParamsJson)) { try { using var doc = JsonDocument.Parse(rule.ParamsJson!); var root = doc.RootElement; if (root.ValueKind == JsonValueKind.Object) { // 只读 B 类三键。旧 JSON 里的 A 类字段就在同一个对象里, // 这里**不读**它们 —— 忽略是刻意的,不是遗漏。 if (root.TryGetProperty(KeyGraceMinutes, out var g) && g.ValueKind == JsonValueKind.Number && g.TryGetInt32(out var gv)) graceMinutes = gv; occurrenceDeptId = ReadNullableLong(root, KeyDefaultOccurrenceDeptId); responsibleDeptId = ReadNullableLong(root, KeyDefaultResponsibleDeptId); } } catch (JsonException) { // 非法 JSON:回落默认值。历史脏数据不该把规则打死。 } } if (!policy.AllowsDepartmentDefaults) { occurrenceDeptId = null; responsibleDeptId = null; } return new S8RuleRuntimeParameters { PollIntervalSeconds = Clamp(rule.PollIntervalSeconds, policy.PollIntervalSecondsMin, policy.PollIntervalSecondsMax, policy.PollIntervalSecondsDefault), TriggerCountRequired = Clamp(rule.TriggerCountRequired, policy.TriggerCountRequiredMin, policy.TriggerCountRequiredMax, policy.TriggerCountRequiredDefault), RecoverCountRequired = Clamp(rule.RecoverCountRequired, policy.RecoverCountRequiredMin, policy.RecoverCountRequiredMax, policy.RecoverCountRequiredDefault), Severity = ResolveSeverity(rule.Severity, policy), GraceMinutes = Clamp(graceMinutes, policy.GraceMinutesMin, policy.GraceMinutesMax, policy.GraceMinutesDefault), DefaultOccurrenceDeptId = occurrenceDeptId, DefaultResponsibleDeptId = responsibleDeptId }; } /// /// 把 B 类参数序列化回 params_json。 /// 只写这三个键:任何旧 A 类字段在这次写入后自然消失,无需 migration 也不会被再次读到。 /// public string ToParamsJson() { var payload = new Dictionary(StringComparer.Ordinal) { [KeyGraceMinutes] = GraceMinutes }; if (DefaultOccurrenceDeptId.HasValue) payload[KeyDefaultOccurrenceDeptId] = DefaultOccurrenceDeptId.Value; if (DefaultResponsibleDeptId.HasValue) payload[KeyDefaultResponsibleDeptId] = DefaultResponsibleDeptId.Value; return JsonSerializer.Serialize(payload); } private static long? ReadNullableLong(JsonElement root, string key) { if (!root.TryGetProperty(key, out var el)) return null; if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v)) return v > 0 ? v : null; // 历史数据里存在字符串化的 id,容忍解析但不容忍非法值。 if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out var sv)) return sv > 0 ? sv : null; return null; } private static int Clamp(int value, int min, int max, int fallback) { if (value < min || value > max) return fallback; return value; } /// /// 严重度解析。**刻意不复用 S8SeverityCode.Normalize**:它的兜底分支是 /// _ => Follow,会把 legacy 的 HIGH / 拼写错误静默降级成 FOLLOW; /// 那正是库里 severity='HIGH' 的行当年混进来的方式。这里回落 Definition 声明的默认值。 /// private static string ResolveSeverity(string? severity, S8RuleParameterPolicy policy) { if (!string.IsNullOrWhiteSpace(severity) && policy.AllowedSeverities.Contains(severity!.Trim(), StringComparer.Ordinal)) return severity!.Trim(); return policy.SeverityDefault; } }