Преглед изворни кода

feat(s8): rebuild watch rule from config wizard draft

YY968XX пре 3 недеља
родитељ
комит
ee197322e5

+ 5 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8ConfigDraftDtos.cs

@@ -72,7 +72,11 @@ public class AdoS8ConfigDraftDetailDto : AdoS8ConfigDraftListItemDto
 
 public class AdoS8ConfigDraftGenerateRuleDto
 {
-    /// <summary>前端构造好的完整 watch_rule payload;首版不从 wizard_json 重建。</summary>
+    /// <summary>
+    /// 兼容模式(CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1):
+    /// - 非空:沿用旧路径,直接信任前端构造好的 watch_rule payload。
+    /// - null/缺省:后端基于 draft.wizard_json + 字典(monitor_object/metric, exception_type, data_source, scene_config)重建。
+    /// </summary>
     public AdoS8WatchRule? RulePayload { get; set; }
 }
 

+ 358 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ConfigDraftService.cs

@@ -1,3 +1,6 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 
@@ -13,15 +16,46 @@ public class S8ConfigDraftService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8ConfigDraft> _rep;
     private readonly S8WatchRuleService _watchRuleService;
+    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:rulePayload 缺省时按 wizard_json 重建用到的字典 Repository。
+    private readonly SqlSugarRepository<AdoS8MonitorObject> _monitorObjectRep;
+    private readonly SqlSugarRepository<AdoS8MonitorMetric> _monitorMetricRep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
+    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
 
     public S8ConfigDraftService(
         SqlSugarRepository<AdoS8ConfigDraft> rep,
-        S8WatchRuleService watchRuleService)
+        S8WatchRuleService watchRuleService,
+        SqlSugarRepository<AdoS8MonitorObject> monitorObjectRep,
+        SqlSugarRepository<AdoS8MonitorMetric> monitorMetricRep,
+        SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
+        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        SqlSugarRepository<AdoS8SceneConfig> sceneRep)
     {
         _rep = rep;
         _watchRuleService = watchRuleService;
+        _monitorObjectRep = monitorObjectRep;
+        _monitorMetricRep = monitorMetricRep;
+        _exceptionTypeRep = exceptionTypeRep;
+        _dataSourceRep = dataSourceRep;
+        _sceneRep = sceneRep;
     }
 
+    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:解析 wizard_json 用 JsonSerializerOptions(前端 camelCase)。
+    private static readonly JsonSerializerOptions WizardJsonReadOptions = new()
+    {
+        PropertyNameCaseInsensitive = true,
+        ReadCommentHandling = JsonCommentHandling.Skip,
+        AllowTrailingCommas = true,
+    };
+
+    // 后端生成 params_json 时 camelCase + 忽略 null。
+    private static readonly JsonSerializerOptions ParamsJsonWriteOptions = new()
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+    };
+
     public async Task<AdoS8ConfigDraftListResultDto> ListAsync(AdoS8ConfigDraftListQueryDto query)
     {
         var page = query.Page < 1 ? 1 : query.Page;
@@ -150,9 +184,10 @@ public class S8ConfigDraftService : ITransient
         if (draft.Status == S8ConfigDraftStatus.Generated)
             throw new S8BizException("草稿状态异常:已标记 GENERATED 但未回写规则 ID");
 
-        var payload = dto.RulePayload ?? throw new S8BizException("rulePayload 必填");
+        // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:兼容模式 — RulePayload 缺省时按 wizard_json 重建。
+        var payload = dto.RulePayload ?? await RebuildPayloadFromWizardJsonAsync(draft);
         if (string.IsNullOrWhiteSpace(payload.RuleCode))
-            throw new S8BizException("rulePayload.ruleCode 必填");
+            throw new S8BizException("规则编码不能为空");
 
         // 安全收口:强制对齐草稿租户/工厂、强制 enabled=false。
         payload.Id = 0;
@@ -241,4 +276,324 @@ public class S8ConfigDraftService : ITransient
         var trimmed = value.Trim();
         return trimmed.Length == 0 ? null : trimmed;
     }
+
+    // ============================================================
+    // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:基于 wizard_json + 字典重建 AdoS8WatchRule
+    // ============================================================
+
+    /// <summary>
+    /// 从草稿的 wizard_json 解析向导态,结合 monitor object/metric、exception_type、data_source、scene_config
+    /// 重建 AdoS8WatchRule。所有错误统一抛 S8BizException → Controller 转 400。
+    /// </summary>
+    private async Task<AdoS8WatchRule> RebuildPayloadFromWizardJsonAsync(AdoS8ConfigDraft draft)
+    {
+        if (string.IsNullOrWhiteSpace(draft.WizardJson))
+            throw new S8BizException("草稿配置内容为空");
+
+        S8WizardJsonV1 parsed;
+        try
+        {
+            parsed = JsonSerializer.Deserialize<S8WizardJsonV1>(draft.WizardJson, WizardJsonReadOptions)
+                     ?? throw new S8BizException("草稿配置内容为空");
+        }
+        catch (JsonException)
+        {
+            throw new S8BizException("草稿配置内容格式错误");
+        }
+
+        if (parsed.Version != 1)
+            throw new S8BizException($"暂不支持该草稿版本(version={parsed.Version})");
+
+        var form = parsed.Form ?? throw new S8BizException("草稿配置内容不完整");
+        var labels = parsed.Labels;
+
+        // 必填基础字段
+        var ruleCode = NormalizeOrNull(form.RuleCode) ?? throw new S8BizException("规则编码不能为空");
+        var mechanism = NormalizeOrNull(form.Mechanism) ?? throw new S8BizException("报警机制未选择");
+        if (mechanism == "MANUAL_REPORT")
+            throw new S8BizException("主动提报无需生成自动监控规则");
+        var stageCode = NormalizeOrNull(form.StageCode) ?? throw new S8BizException("阶段维度未选择");
+        var exceptionTypeCode = NormalizeOrNull(form.ExceptionTypeCode) ?? throw new S8BizException("异常类型未选择");
+        var objectType = NormalizeOrNull(form.ObjectType) ?? throw new S8BizException("监控对象未选择");
+        var objectLabel = NormalizeOrNull(form.ObjectLabel) ?? throw new S8BizException("监控对象未选择");
+        var metricCode = NormalizeOrNull(form.MetricCode) ?? throw new S8BizException("监控指标未选择");
+
+        // 监控对象(用 objectType + objectName 在字典里反查;tenant 覆盖 baseline)
+        var monitorObject = await ResolveMonitorObjectAsync(draft.TenantId, draft.FactoryId, objectType, objectLabel);
+
+        // 监控指标
+        var metric = await ResolveMonitorMetricAsync(draft.TenantId, draft.FactoryId, metricCode, mechanism);
+
+        // 异常类型(baseline 0/0 + tenant 覆盖;enabled 必须 true;sceneCode 必须等于 stageCode)
+        var exceptionType = await ResolveExceptionTypeAsync(draft.TenantId, draft.FactoryId, exceptionTypeCode);
+        if (!string.IsNullOrWhiteSpace(exceptionType.SceneCode) && exceptionType.SceneCode != stageCode)
+            throw new S8BizException("异常类型所属阶段与当前规则阶段不一致");
+
+        // severity 优先 wizard_json,再 exceptionType.SeverityDefault,再 FOLLOW
+        var severity = NormalizeOrNull(form.Severity)
+                       ?? NormalizeOrNull(exceptionType.SeverityDefault)
+                       ?? "FOLLOW";
+
+        // dataSource:tenant/factory 下第一个 enabled
+        var dataSource = await _dataSourceRep.AsQueryable()
+            .Where(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.Enabled)
+            .OrderBy(x => x.Id)
+            .FirstAsync()
+            ?? throw new S8BizException("未找到可用数据源");
+
+        // scene_config:tenant/factory + stageCode + enabled(CreateAsync 也会校验,提前查给更清晰错误)
+        var scene = await _sceneRep.AsQueryable()
+            .FirstAsync(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.SceneCode == stageCode)
+            ?? throw new S8BizException("规则所属场景不存在");
+        if (!scene.Enabled) throw new S8BizException("规则所属场景未启用");
+
+        // params_json + expression
+        var paramsJson = BuildParamsJson(form, labels, mechanism, metric, monitorObject, exceptionTypeCode);
+        var expression = BuildExpression(mechanism);
+
+        return new AdoS8WatchRule
+        {
+            TenantId = draft.TenantId,
+            FactoryId = draft.FactoryId,
+            RuleCode = ruleCode,
+            SceneCode = stageCode,
+            DataSourceId = dataSource.Id,
+            WatchObjectType = monitorObject.ObjectType,
+            RuleType = RuleTypeOf(mechanism),
+            RuleMechanism = mechanism,
+            StageCode = stageCode,
+            OrderFlowCode = NormalizeOrNull(form.OrderFlowCode),
+            SourceObjectType = monitorObject.ObjectType,
+            Severity = severity,
+            Expression = expression,
+            ParamsJson = paramsJson,
+            PollIntervalSeconds = form.PollIntervalSeconds is > 0 ? form.PollIntervalSeconds.Value : 300,
+            TriggerCountRequired = form.TriggerCountRequired is > 0 ? form.TriggerCountRequired.Value : 1,
+            RecoverCountRequired = form.RecoverCountRequired is > 0 ? form.RecoverCountRequired.Value : 1,
+            ConsecutiveFailureCount = 0,
+            Enabled = false,
+        };
+    }
+
+    private async Task<AdoS8MonitorObject> ResolveMonitorObjectAsync(long tenantId, long factoryId, string objectType, string objectName)
+    {
+        var rows = await _monitorObjectRep.AsQueryable()
+            .Where(x => x.ObjectType == objectType && x.ObjectName == objectName
+                && ((x.TenantId == 0 && x.FactoryId == 0)
+                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
+            .ToListAsync();
+        var picked = rows
+            .OrderByDescending(x => x.FactoryId)
+            .ThenByDescending(x => x.TenantId)
+            .FirstOrDefault();
+        if (picked == null || !picked.Enabled)
+            throw new S8BizException($"监控对象 '{objectName}' 不存在或未启用");
+        return picked;
+    }
+
+    private async Task<AdoS8MonitorMetric> ResolveMonitorMetricAsync(long tenantId, long factoryId, string metricCode, string mechanism)
+    {
+        var rows = await _monitorMetricRep.AsQueryable()
+            .Where(x => x.MetricCode == metricCode
+                && ((x.TenantId == 0 && x.FactoryId == 0)
+                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
+            .ToListAsync();
+        var picked = rows
+            .OrderByDescending(x => x.FactoryId)
+            .ThenByDescending(x => x.TenantId)
+            .FirstOrDefault();
+        if (picked == null)
+            throw new S8BizException($"监控指标 '{metricCode}' 不存在");
+        if (!picked.Enabled)
+        {
+            var label = string.IsNullOrWhiteSpace(picked.MetricName) ? picked.MetricCode : picked.MetricName;
+            throw new S8BizException($"监控指标 '{label}' 未启用,请先在监控指标字典中启用后再生成规则");
+        }
+        if (!string.IsNullOrWhiteSpace(picked.Mechanism) && picked.Mechanism != mechanism)
+            throw new S8BizException("监控指标与报警机制不匹配");
+        return picked;
+    }
+
+    private async Task<AdoS8ExceptionType> ResolveExceptionTypeAsync(long tenantId, long factoryId, string typeCode)
+    {
+        var rows = await _exceptionTypeRep.AsQueryable()
+            .Where(x => x.TypeCode == typeCode
+                && ((x.TenantId == 0 && x.FactoryId == 0)
+                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
+            .ToListAsync();
+        var picked = rows.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).FirstOrDefault();
+        if (picked == null) throw new S8BizException($"异常类型 '{typeCode}' 不存在");
+        if (!picked.Enabled) throw new S8BizException($"异常类型 '{picked.TypeName}' 未启用");
+        return picked;
+    }
+
+    private static string RuleTypeOf(string mechanism) => mechanism switch
+    {
+        "DATE" => "TIMEOUT",
+        "VALUE_RANGE" => "OUT_OF_RANGE",
+        "RATIO" => "OUT_OF_RANGE",
+        "MANUAL_REPORT" => throw new S8BizException("主动提报无需生成自动监控规则"),
+        _ => throw new S8BizException($"不支持的报警机制:{mechanism}"),
+    };
+
+    private static string BuildExpression(string mechanism) =>
+        mechanism == "DATE"
+            ? "SELECT 'DEMO-OBJECT-001' AS related_object_code, 'DEMO-OBJECT-001' AS source_object_id, 'DEMO-OBJECT-001' AS related_object_name, DATE_SUB(NOW(), INTERVAL 1 HOUR) AS due_at, 'PENDING' AS status WHERE 1=0"
+            : "SELECT 'DEMO-OBJECT-001' AS related_object_code, 'DEMO-OBJECT-001' AS source_object_id, 'DEMO-OBJECT-001' AS related_object_name, 0 AS measured_value WHERE 1=0";
+
+    private static string BuildParamsJson(
+        S8WizardFormV1 form,
+        S8WizardLabelsV1? labels,
+        string mechanism,
+        AdoS8MonitorMetric metric,
+        AdoS8MonitorObject monitorObject,
+        string exceptionTypeCode)
+    {
+        var objectLabel = NormalizeOrNull(labels?.ObjectLabel)
+                          ?? NormalizeOrNull(form.ObjectLabel)
+                          ?? monitorObject.ObjectName;
+        var metricLabel = NormalizeOrNull(labels?.MetricLabel)
+                          ?? NormalizeOrNull(form.MetricLabel)
+                          ?? metric.MetricName;
+        var unit = NormalizeOrNull(form.Unit) ?? metric.Unit;
+        var thresholdDisplay = NormalizeOrNull(labels?.ThresholdDisplay)
+                               ?? BuildThresholdDisplay(mechanism, form, metric, unit);
+
+        var objectIdField = NormalizeOrNull(metric.ObjectIdField) ?? "source_object_id";
+        var objectCodeField = NormalizeOrNull(metric.ObjectCodeField) ?? "related_object_code";
+        var objectNameField = NormalizeOrNull(metric.ObjectNameField) ?? "related_object_name";
+
+        var dict = new Dictionary<string, object?>
+        {
+            ["objectIdField"] = objectIdField,
+            ["objectCodeField"] = objectCodeField,
+            ["objectNameField"] = objectNameField,
+            ["exceptionTypeCode"] = exceptionTypeCode,
+            ["objectLabel"] = objectLabel,
+            ["metricLabel"] = metricLabel,
+        };
+
+        if (mechanism == "DATE")
+        {
+            var states = ParseCsvStates(form.CompletedStates);
+            if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
+            if (states.Count == 0) states = new List<string> { "CLOSED", "COMPLETED", "DONE" };
+
+            var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
+            dict["dueAtField"] = NormalizeOrNull(metric.DueAtField) ?? "due_at";
+            dict["statusField"] = NormalizeOrNull(metric.StatusField) ?? "status";
+            dict["completedStates"] = states;
+            dict["graceMinutes"] = grace;
+            dict["unit"] = unit ?? "分钟";
+            dict["thresholdDisplay"] = thresholdDisplay;
+        }
+        else if (mechanism == "VALUE_RANGE")
+        {
+            var lower = form.LowerBound ?? metric.DefaultLowerBound;
+            var upper = form.UpperBound ?? metric.DefaultUpperBound;
+            if (lower == null && upper == null)
+                throw new S8BizException("请配置上限或下限至少其一");
+            dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
+            dict["unit"] = unit;
+            dict["thresholdDisplay"] = thresholdDisplay;
+            if (lower.HasValue) dict["lowerBound"] = lower.Value;
+            if (upper.HasValue) dict["upperBound"] = upper.Value;
+            if (form.ToleranceAbs is > 0) dict["toleranceAbs"] = form.ToleranceAbs.Value;
+            if (form.ToleranceRatioPct is > 0) dict["toleranceRatio"] = form.ToleranceRatioPct.Value / 100m;
+        }
+        else if (mechanism == "RATIO")
+        {
+            var target = form.TargetRatio ?? metric.DefaultTargetRatio;
+            if (!target.HasValue) throw new S8BizException("请配置目标比例");
+            dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
+            dict["lowerBound"] = target.Value;
+            dict["unit"] = "%";
+            dict["thresholdDisplay"] = thresholdDisplay;
+        }
+        else
+        {
+            throw new S8BizException($"不支持的报警机制:{mechanism}");
+        }
+
+        return JsonSerializer.Serialize(dict, ParamsJsonWriteOptions);
+    }
+
+    private static List<string> ParseCsvStates(string? csv)
+    {
+        if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
+        return csv.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+                  .ToList();
+    }
+
+    private static string BuildThresholdDisplay(string mechanism, S8WizardFormV1 form, AdoS8MonitorMetric metric, string? unit)
+    {
+        var u = unit ?? string.Empty;
+        if (mechanism == "DATE")
+        {
+            var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
+            return grace > 0 ? $"超期 {grace} 分钟仍未完成触发" : "超期即触发";
+        }
+        if (mechanism == "RATIO")
+        {
+            var target = form.TargetRatio ?? metric.DefaultTargetRatio;
+            return target.HasValue
+                ? string.Format(CultureInfo.InvariantCulture, "低于 {0}% 触发", target.Value)
+                : "低于目标比例触发";
+        }
+        if (mechanism == "VALUE_RANGE")
+        {
+            var lower = form.LowerBound ?? metric.DefaultLowerBound;
+            var upper = form.UpperBound ?? metric.DefaultUpperBound;
+            var segs = new List<string>();
+            if (lower.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "低于 {0}{1}", lower.Value, u));
+            if (upper.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "高于 {0}{1}", upper.Value, u));
+            return segs.Count > 0 ? string.Join(" 或 ", segs) + " 触发" : "超出设定范围触发";
+        }
+        return string.Empty;
+    }
+
+    // ----- wizard_json 解析私有 DTO -----
+
+    private sealed class S8WizardJsonV1
+    {
+        public int Version { get; set; }
+        public int Step { get; set; }
+        public S8WizardFormV1? Form { get; set; }
+        public S8WizardLabelsV1? Labels { get; set; }
+    }
+
+    private sealed class S8WizardFormV1
+    {
+        public string? Mechanism { get; set; }
+        public string? StageCode { get; set; }
+        public string? OrderFlowCode { get; set; }
+        public string? ExceptionTypeCode { get; set; }
+        public string? Severity { get; set; }
+        public string? ObjectType { get; set; }
+        public string? ObjectLabel { get; set; }
+        public string? MetricCode { get; set; }
+        public string? MetricLabel { get; set; }
+        public string? Unit { get; set; }
+        public int? GraceMinutes { get; set; }
+        // 前端 wizard_json 中 completedStates 是 CSV 字符串(如 "CLOSED,COMPLETED,DONE"),不是数组
+        public string? CompletedStates { get; set; }
+        public decimal? LowerBound { get; set; }
+        public decimal? UpperBound { get; set; }
+        public decimal? ToleranceAbs { get; set; }
+        public decimal? ToleranceRatioPct { get; set; }
+        public decimal? TargetRatio { get; set; }
+        public string? RuleCode { get; set; }
+        public int? PollIntervalSeconds { get; set; }
+        public int? TriggerCountRequired { get; set; }
+        public int? RecoverCountRequired { get; set; }
+    }
+
+    private sealed class S8WizardLabelsV1
+    {
+        public string? MechanismLabel { get; set; }
+        public string? StageLabel { get; set; }
+        public string? ObjectLabel { get; set; }
+        public string? MetricLabel { get; set; }
+        public string? ThresholdDisplay { get; set; }
+    }
 }