S8ConfigDraftService.cs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. using System.Globalization;
  2. using System.Text.Json;
  3. using System.Text.Json.Serialization;
  4. using Admin.NET.Plugin.AiDOP.Dto.S8;
  5. using Admin.NET.Plugin.AiDOP.Entity.S8;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  7. internal static class S8ConfigDraftStatus
  8. {
  9. public const string InProgress = "IN_PROGRESS";
  10. public const string Generated = "GENERATED";
  11. }
  12. public class S8ConfigDraftService : ITransient
  13. {
  14. private readonly SqlSugarRepository<AdoS8ConfigDraft> _rep;
  15. private readonly S8WatchRuleService _watchRuleService;
  16. // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:rulePayload 缺省时按 wizard_json 重建用到的字典 Repository。
  17. private readonly SqlSugarRepository<AdoS8MonitorObject> _monitorObjectRep;
  18. private readonly SqlSugarRepository<AdoS8MonitorMetric> _monitorMetricRep;
  19. private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
  20. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  21. private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
  22. public S8ConfigDraftService(
  23. SqlSugarRepository<AdoS8ConfigDraft> rep,
  24. S8WatchRuleService watchRuleService,
  25. SqlSugarRepository<AdoS8MonitorObject> monitorObjectRep,
  26. SqlSugarRepository<AdoS8MonitorMetric> monitorMetricRep,
  27. SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
  28. SqlSugarRepository<AdoS8DataSource> dataSourceRep,
  29. SqlSugarRepository<AdoS8SceneConfig> sceneRep)
  30. {
  31. _rep = rep;
  32. _watchRuleService = watchRuleService;
  33. _monitorObjectRep = monitorObjectRep;
  34. _monitorMetricRep = monitorMetricRep;
  35. _exceptionTypeRep = exceptionTypeRep;
  36. _dataSourceRep = dataSourceRep;
  37. _sceneRep = sceneRep;
  38. }
  39. // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:解析 wizard_json 用 JsonSerializerOptions(前端 camelCase)。
  40. private static readonly JsonSerializerOptions WizardJsonReadOptions = new()
  41. {
  42. PropertyNameCaseInsensitive = true,
  43. ReadCommentHandling = JsonCommentHandling.Skip,
  44. AllowTrailingCommas = true,
  45. };
  46. // 后端生成 params_json 时 camelCase + 忽略 null。
  47. private static readonly JsonSerializerOptions ParamsJsonWriteOptions = new()
  48. {
  49. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  50. DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
  51. };
  52. public async Task<AdoS8ConfigDraftListResultDto> ListAsync(AdoS8ConfigDraftListQueryDto query)
  53. {
  54. var page = query.Page < 1 ? 1 : query.Page;
  55. var pageSize = query.PageSize < 1 ? 20 : Math.Min(query.PageSize, 200);
  56. var status = string.IsNullOrWhiteSpace(query.Status) ? null : query.Status.Trim();
  57. var q = _rep.AsQueryable()
  58. .Where(x => x.TenantId == query.TenantId && x.FactoryId == query.FactoryId);
  59. if (status != null)
  60. q = q.Where(x => x.Status == status);
  61. RefAsync<int> total = 0;
  62. var rows = await q
  63. .OrderBy(x => x.Id, OrderByType.Desc)
  64. .Select(x => new AdoS8ConfigDraftListItemDto
  65. {
  66. Id = x.Id,
  67. TenantId = x.TenantId,
  68. FactoryId = x.FactoryId,
  69. DraftCode = x.DraftCode,
  70. DraftName = x.DraftName,
  71. Status = x.Status,
  72. CurrentStep = x.CurrentStep,
  73. Mechanism = x.Mechanism,
  74. StageCode = x.StageCode,
  75. OrderFlowCode = x.OrderFlowCode,
  76. ExceptionTypeCode = x.ExceptionTypeCode,
  77. GeneratedRuleId = x.GeneratedRuleId,
  78. Remark = x.Remark,
  79. CreatedAt = x.CreatedAt,
  80. UpdatedAt = x.UpdatedAt
  81. })
  82. .ToPageListAsync(page, pageSize, total);
  83. return new AdoS8ConfigDraftListResultDto
  84. {
  85. Items = rows,
  86. Total = total.Value,
  87. Page = page,
  88. PageSize = pageSize
  89. };
  90. }
  91. public async Task<AdoS8ConfigDraftDetailDto> GetAsync(long id)
  92. {
  93. var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("草稿不存在");
  94. return ToDetail(e);
  95. }
  96. public async Task<AdoS8ConfigDraftDetailDto> CreateAsync(AdoS8ConfigDraftCreateDto dto)
  97. {
  98. if (string.IsNullOrWhiteSpace(dto.DraftCode))
  99. throw new S8BizException("草稿编码必填");
  100. if (string.IsNullOrWhiteSpace(dto.WizardJson))
  101. throw new S8BizException("草稿 wizard_json 必填");
  102. var draftCode = dto.DraftCode.Trim();
  103. var exists = await _rep.AsQueryable()
  104. .AnyAsync(x => x.TenantId == dto.TenantId && x.FactoryId == dto.FactoryId && x.DraftCode == draftCode);
  105. if (exists) throw new S8BizException("草稿编码已存在");
  106. var entity = new AdoS8ConfigDraft
  107. {
  108. TenantId = dto.TenantId,
  109. FactoryId = dto.FactoryId,
  110. DraftCode = draftCode,
  111. DraftName = NormalizeOrNull(dto.DraftName),
  112. WizardJson = dto.WizardJson,
  113. CurrentStep = dto.CurrentStep ?? 0,
  114. Mechanism = NormalizeOrNull(dto.Mechanism),
  115. StageCode = NormalizeOrNull(dto.StageCode),
  116. OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode),
  117. ExceptionTypeCode = NormalizeOrNull(dto.ExceptionTypeCode),
  118. Remark = NormalizeOrNull(dto.Remark),
  119. Status = S8ConfigDraftStatus.InProgress,
  120. CreatedAt = DateTime.Now
  121. };
  122. entity.Id = await _rep.AsInsertable(entity).ExecuteReturnBigIdentityAsync();
  123. return ToDetail(entity);
  124. }
  125. public async Task<AdoS8ConfigDraftDetailDto> UpdateAsync(long id, AdoS8ConfigDraftUpdateDto dto)
  126. {
  127. var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("草稿不存在");
  128. if (e.Status == S8ConfigDraftStatus.Generated)
  129. throw new S8BizException("草稿已生成正式规则,禁止再编辑");
  130. if (dto.DraftName != null) e.DraftName = NormalizeOrNull(dto.DraftName);
  131. if (dto.WizardJson != null)
  132. {
  133. if (string.IsNullOrWhiteSpace(dto.WizardJson))
  134. throw new S8BizException("wizard_json 不能为空字符串");
  135. e.WizardJson = dto.WizardJson;
  136. }
  137. if (dto.CurrentStep.HasValue) e.CurrentStep = dto.CurrentStep.Value;
  138. if (dto.Mechanism != null) e.Mechanism = NormalizeOrNull(dto.Mechanism);
  139. if (dto.StageCode != null) e.StageCode = NormalizeOrNull(dto.StageCode);
  140. if (dto.OrderFlowCode != null) e.OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode);
  141. if (dto.ExceptionTypeCode != null) e.ExceptionTypeCode = NormalizeOrNull(dto.ExceptionTypeCode);
  142. if (dto.Remark != null) e.Remark = NormalizeOrNull(dto.Remark);
  143. e.UpdatedAt = DateTime.Now;
  144. await _rep.UpdateAsync(e);
  145. return ToDetail(e);
  146. }
  147. public async Task DeleteAsync(long id)
  148. {
  149. var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("草稿不存在");
  150. await _rep.DeleteByIdAsync(e.Id);
  151. }
  152. public async Task<AdoS8ConfigDraftGenerateRuleResultDto> GenerateRuleAsync(long id, AdoS8ConfigDraftGenerateRuleDto dto)
  153. {
  154. var draft = await _rep.GetByIdAsync(id) ?? throw new S8BizException("草稿不存在");
  155. if (draft.GeneratedRuleId.HasValue)
  156. {
  157. return new AdoS8ConfigDraftGenerateRuleResultDto
  158. {
  159. RuleId = draft.GeneratedRuleId.Value,
  160. RuleCode = await ResolveExistingRuleCodeAsync(draft.GeneratedRuleId.Value),
  161. DraftStatus = draft.Status
  162. };
  163. }
  164. if (draft.Status == S8ConfigDraftStatus.Generated)
  165. throw new S8BizException("草稿状态异常:已标记 GENERATED 但未回写规则 ID");
  166. // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:兼容模式 — RulePayload 缺省时按 wizard_json 重建。
  167. var payload = dto.RulePayload ?? await RebuildPayloadFromWizardJsonAsync(draft);
  168. if (string.IsNullOrWhiteSpace(payload.RuleCode))
  169. throw new S8BizException("规则编码不能为空");
  170. // 安全收口:强制对齐草稿租户/工厂、强制 enabled=false。
  171. payload.Id = 0;
  172. payload.TenantId = draft.TenantId;
  173. payload.FactoryId = draft.FactoryId;
  174. payload.Enabled = false;
  175. var db = _rep.Context;
  176. await db.Ado.BeginTranAsync();
  177. try
  178. {
  179. var created = await _watchRuleService.CreateAsync(payload);
  180. // S8WatchRuleService.CreateAsync 内部用 InsertAsync 不回填 Id;按 RuleCode 反查真实 id。
  181. var newRuleId = created.Id;
  182. if (newRuleId <= 0)
  183. {
  184. newRuleId = await db.Queryable<AdoS8WatchRule>()
  185. .Where(x => x.TenantId == draft.TenantId
  186. && x.FactoryId == draft.FactoryId
  187. && x.RuleCode == created.RuleCode)
  188. .Select(x => x.Id)
  189. .FirstAsync();
  190. }
  191. if (newRuleId <= 0)
  192. throw new S8BizException("生成规则失败:无法定位新规则 id");
  193. await db.Updateable<AdoS8ConfigDraft>()
  194. .SetColumns(x => new AdoS8ConfigDraft
  195. {
  196. GeneratedRuleId = newRuleId,
  197. Status = S8ConfigDraftStatus.Generated,
  198. UpdatedAt = DateTime.Now
  199. })
  200. .Where(x => x.Id == draft.Id)
  201. .ExecuteCommandAsync();
  202. await db.Ado.CommitTranAsync();
  203. return new AdoS8ConfigDraftGenerateRuleResultDto
  204. {
  205. RuleId = newRuleId,
  206. RuleCode = created.RuleCode,
  207. DraftStatus = S8ConfigDraftStatus.Generated
  208. };
  209. }
  210. catch
  211. {
  212. await db.Ado.RollbackTranAsync();
  213. throw;
  214. }
  215. }
  216. private async Task<string> ResolveExistingRuleCodeAsync(long ruleId)
  217. {
  218. var rule = await _rep.Context.Queryable<AdoS8WatchRule>()
  219. .Where(x => x.Id == ruleId)
  220. .Select(x => new { x.RuleCode })
  221. .FirstAsync();
  222. return rule?.RuleCode ?? string.Empty;
  223. }
  224. private static AdoS8ConfigDraftDetailDto ToDetail(AdoS8ConfigDraft e) => new()
  225. {
  226. Id = e.Id,
  227. TenantId = e.TenantId,
  228. FactoryId = e.FactoryId,
  229. DraftCode = e.DraftCode,
  230. DraftName = e.DraftName,
  231. Status = e.Status,
  232. CurrentStep = e.CurrentStep,
  233. Mechanism = e.Mechanism,
  234. StageCode = e.StageCode,
  235. OrderFlowCode = e.OrderFlowCode,
  236. ExceptionTypeCode = e.ExceptionTypeCode,
  237. GeneratedRuleId = e.GeneratedRuleId,
  238. Remark = e.Remark,
  239. CreatedAt = e.CreatedAt,
  240. UpdatedAt = e.UpdatedAt,
  241. WizardJson = e.WizardJson
  242. };
  243. private static string? NormalizeOrNull(string? value)
  244. {
  245. if (string.IsNullOrWhiteSpace(value)) return null;
  246. var trimmed = value.Trim();
  247. return trimmed.Length == 0 ? null : trimmed;
  248. }
  249. // ============================================================
  250. // CONFIG-WIZARD-GENERATE-RULE-SERVER-BUILD-1:基于 wizard_json + 字典重建 AdoS8WatchRule
  251. // ============================================================
  252. /// <summary>
  253. /// 从草稿的 wizard_json 解析向导态,结合 monitor object/metric、exception_type、data_source、scene_config
  254. /// 重建 AdoS8WatchRule。所有错误统一抛 S8BizException → Controller 转 400。
  255. /// </summary>
  256. private async Task<AdoS8WatchRule> RebuildPayloadFromWizardJsonAsync(AdoS8ConfigDraft draft)
  257. {
  258. if (string.IsNullOrWhiteSpace(draft.WizardJson))
  259. throw new S8BizException("草稿配置内容为空");
  260. S8WizardJsonV1 parsed;
  261. try
  262. {
  263. parsed = JsonSerializer.Deserialize<S8WizardJsonV1>(draft.WizardJson, WizardJsonReadOptions)
  264. ?? throw new S8BizException("草稿配置内容为空");
  265. }
  266. catch (JsonException)
  267. {
  268. throw new S8BizException("草稿配置内容格式错误");
  269. }
  270. if (parsed.Version != 1)
  271. throw new S8BizException($"暂不支持该草稿版本(version={parsed.Version})");
  272. var form = parsed.Form ?? throw new S8BizException("草稿配置内容不完整");
  273. var labels = parsed.Labels;
  274. // 必填基础字段
  275. var ruleCode = NormalizeOrNull(form.RuleCode) ?? throw new S8BizException("规则编码不能为空");
  276. var mechanism = NormalizeOrNull(form.Mechanism) ?? throw new S8BizException("报警机制未选择");
  277. if (mechanism == "MANUAL_REPORT")
  278. throw new S8BizException("主动提报无需生成自动监控规则");
  279. var stageCode = NormalizeOrNull(form.StageCode) ?? throw new S8BizException("阶段维度未选择");
  280. var exceptionTypeCode = NormalizeOrNull(form.ExceptionTypeCode) ?? throw new S8BizException("异常类型未选择");
  281. var objectType = NormalizeOrNull(form.ObjectType) ?? throw new S8BizException("监控对象未选择");
  282. var objectLabel = NormalizeOrNull(form.ObjectLabel) ?? throw new S8BizException("监控对象未选择");
  283. var metricCode = NormalizeOrNull(form.MetricCode) ?? throw new S8BizException("监控指标未选择");
  284. // 监控对象(用 objectType + objectName 在字典里反查;tenant 覆盖 baseline)
  285. var monitorObject = await ResolveMonitorObjectAsync(draft.TenantId, draft.FactoryId, objectType, objectLabel);
  286. // 监控指标
  287. var metric = await ResolveMonitorMetricAsync(draft.TenantId, draft.FactoryId, metricCode, mechanism);
  288. // 异常类型(baseline 0/0 + tenant 覆盖;enabled 必须 true;sceneCode 必须等于 stageCode)
  289. var exceptionType = await ResolveExceptionTypeAsync(draft.TenantId, draft.FactoryId, exceptionTypeCode);
  290. if (!string.IsNullOrWhiteSpace(exceptionType.SceneCode) && exceptionType.SceneCode != stageCode)
  291. throw new S8BizException("异常类型所属阶段与当前规则阶段不一致");
  292. // severity 优先 wizard_json,再 exceptionType.SeverityDefault,再 FOLLOW
  293. var severity = NormalizeOrNull(form.Severity)
  294. ?? NormalizeOrNull(exceptionType.SeverityDefault)
  295. ?? "FOLLOW";
  296. // dataSource:tenant/factory 下第一个 enabled
  297. var dataSource = await _dataSourceRep.AsQueryable()
  298. .Where(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.Enabled)
  299. .OrderBy(x => x.Id)
  300. .FirstAsync()
  301. ?? throw new S8BizException("未找到可用数据源");
  302. // scene_config:tenant/factory + stageCode + enabled(CreateAsync 也会校验,提前查给更清晰错误)
  303. var scene = await _sceneRep.AsQueryable()
  304. .FirstAsync(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.SceneCode == stageCode)
  305. ?? throw new S8BizException("规则所属场景不存在");
  306. if (!scene.Enabled) throw new S8BizException("规则所属场景未启用");
  307. // params_json + expression
  308. var paramsJson = BuildParamsJson(form, labels, mechanism, metric, monitorObject, exceptionTypeCode);
  309. var expression = BuildExpression(mechanism);
  310. return new AdoS8WatchRule
  311. {
  312. TenantId = draft.TenantId,
  313. FactoryId = draft.FactoryId,
  314. RuleCode = ruleCode,
  315. SceneCode = stageCode,
  316. DataSourceId = dataSource.Id,
  317. WatchObjectType = monitorObject.ObjectType,
  318. RuleType = RuleTypeOf(mechanism),
  319. RuleMechanism = mechanism,
  320. StageCode = stageCode,
  321. OrderFlowCode = NormalizeOrNull(form.OrderFlowCode),
  322. SourceObjectType = monitorObject.ObjectType,
  323. Severity = severity,
  324. Expression = expression,
  325. ParamsJson = paramsJson,
  326. PollIntervalSeconds = form.PollIntervalSeconds is > 0 ? form.PollIntervalSeconds.Value : 300,
  327. TriggerCountRequired = form.TriggerCountRequired is > 0 ? form.TriggerCountRequired.Value : 1,
  328. RecoverCountRequired = form.RecoverCountRequired is > 0 ? form.RecoverCountRequired.Value : 1,
  329. ConsecutiveFailureCount = 0,
  330. Enabled = false,
  331. };
  332. }
  333. private async Task<AdoS8MonitorObject> ResolveMonitorObjectAsync(long tenantId, long factoryId, string objectType, string objectName)
  334. {
  335. var rows = await _monitorObjectRep.AsQueryable()
  336. .Where(x => x.ObjectType == objectType && x.ObjectName == objectName
  337. && ((x.TenantId == 0 && x.FactoryId == 0)
  338. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  339. .ToListAsync();
  340. var picked = rows
  341. .OrderByDescending(x => x.FactoryId)
  342. .ThenByDescending(x => x.TenantId)
  343. .FirstOrDefault();
  344. if (picked == null || !picked.Enabled)
  345. throw new S8BizException($"监控对象 '{objectName}' 不存在或未启用");
  346. return picked;
  347. }
  348. private async Task<AdoS8MonitorMetric> ResolveMonitorMetricAsync(long tenantId, long factoryId, string metricCode, string mechanism)
  349. {
  350. var rows = await _monitorMetricRep.AsQueryable()
  351. .Where(x => x.MetricCode == metricCode
  352. && ((x.TenantId == 0 && x.FactoryId == 0)
  353. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  354. .ToListAsync();
  355. var picked = rows
  356. .OrderByDescending(x => x.FactoryId)
  357. .ThenByDescending(x => x.TenantId)
  358. .FirstOrDefault();
  359. if (picked == null)
  360. throw new S8BizException($"监控指标 '{metricCode}' 不存在");
  361. if (!picked.Enabled)
  362. {
  363. var label = string.IsNullOrWhiteSpace(picked.MetricName) ? picked.MetricCode : picked.MetricName;
  364. throw new S8BizException($"监控指标 '{label}' 未启用,请先在监控指标字典中启用后再生成规则");
  365. }
  366. if (!string.IsNullOrWhiteSpace(picked.Mechanism) && picked.Mechanism != mechanism)
  367. throw new S8BizException("监控指标与报警机制不匹配");
  368. return picked;
  369. }
  370. private async Task<AdoS8ExceptionType> ResolveExceptionTypeAsync(long tenantId, long factoryId, string typeCode)
  371. {
  372. var rows = await _exceptionTypeRep.AsQueryable()
  373. .Where(x => x.TypeCode == typeCode
  374. && ((x.TenantId == 0 && x.FactoryId == 0)
  375. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  376. .ToListAsync();
  377. var picked = rows.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).FirstOrDefault();
  378. if (picked == null) throw new S8BizException($"异常类型 '{typeCode}' 不存在");
  379. if (!picked.Enabled) throw new S8BizException($"异常类型 '{picked.TypeName}' 未启用");
  380. return picked;
  381. }
  382. private static string RuleTypeOf(string mechanism) => mechanism switch
  383. {
  384. "DATE" => "TIMEOUT",
  385. "VALUE_RANGE" => "OUT_OF_RANGE",
  386. "RATIO" => "OUT_OF_RANGE",
  387. "MANUAL_REPORT" => throw new S8BizException("主动提报无需生成自动监控规则"),
  388. _ => throw new S8BizException($"不支持的报警机制:{mechanism}"),
  389. };
  390. private static string BuildExpression(string mechanism) =>
  391. mechanism == "DATE"
  392. ? "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"
  393. : "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";
  394. private static string BuildParamsJson(
  395. S8WizardFormV1 form,
  396. S8WizardLabelsV1? labels,
  397. string mechanism,
  398. AdoS8MonitorMetric metric,
  399. AdoS8MonitorObject monitorObject,
  400. string exceptionTypeCode)
  401. {
  402. var objectLabel = NormalizeOrNull(labels?.ObjectLabel)
  403. ?? NormalizeOrNull(form.ObjectLabel)
  404. ?? monitorObject.ObjectName;
  405. var metricLabel = NormalizeOrNull(labels?.MetricLabel)
  406. ?? NormalizeOrNull(form.MetricLabel)
  407. ?? metric.MetricName;
  408. var unit = NormalizeOrNull(form.Unit) ?? metric.Unit;
  409. var thresholdDisplay = NormalizeOrNull(labels?.ThresholdDisplay)
  410. ?? BuildThresholdDisplay(mechanism, form, metric, unit);
  411. var objectIdField = NormalizeOrNull(metric.ObjectIdField) ?? "source_object_id";
  412. var objectCodeField = NormalizeOrNull(metric.ObjectCodeField) ?? "related_object_code";
  413. var objectNameField = NormalizeOrNull(metric.ObjectNameField) ?? "related_object_name";
  414. var dict = new Dictionary<string, object?>
  415. {
  416. ["objectIdField"] = objectIdField,
  417. ["objectCodeField"] = objectCodeField,
  418. ["objectNameField"] = objectNameField,
  419. ["exceptionTypeCode"] = exceptionTypeCode,
  420. ["objectLabel"] = objectLabel,
  421. ["metricLabel"] = metricLabel,
  422. };
  423. if (mechanism == "DATE")
  424. {
  425. var states = ParseCsvStates(form.CompletedStates);
  426. if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
  427. if (states.Count == 0) states = new List<string> { "CLOSED", "COMPLETED", "DONE" };
  428. var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
  429. dict["dueAtField"] = NormalizeOrNull(metric.DueAtField) ?? "due_at";
  430. dict["statusField"] = NormalizeOrNull(metric.StatusField) ?? "status";
  431. dict["completedStates"] = states;
  432. dict["graceMinutes"] = grace;
  433. dict["unit"] = unit ?? "分钟";
  434. dict["thresholdDisplay"] = thresholdDisplay;
  435. }
  436. else if (mechanism == "VALUE_RANGE")
  437. {
  438. var lower = form.LowerBound ?? metric.DefaultLowerBound;
  439. var upper = form.UpperBound ?? metric.DefaultUpperBound;
  440. if (lower == null && upper == null)
  441. throw new S8BizException("请配置上限或下限至少其一");
  442. dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
  443. dict["unit"] = unit;
  444. dict["thresholdDisplay"] = thresholdDisplay;
  445. if (lower.HasValue) dict["lowerBound"] = lower.Value;
  446. if (upper.HasValue) dict["upperBound"] = upper.Value;
  447. if (form.ToleranceAbs is > 0) dict["toleranceAbs"] = form.ToleranceAbs.Value;
  448. if (form.ToleranceRatioPct is > 0) dict["toleranceRatio"] = form.ToleranceRatioPct.Value / 100m;
  449. }
  450. else if (mechanism == "RATIO")
  451. {
  452. var target = form.TargetRatio ?? metric.DefaultTargetRatio;
  453. if (!target.HasValue) throw new S8BizException("请配置目标比例");
  454. dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
  455. dict["lowerBound"] = target.Value;
  456. dict["unit"] = "%";
  457. dict["thresholdDisplay"] = thresholdDisplay;
  458. }
  459. else
  460. {
  461. throw new S8BizException($"不支持的报警机制:{mechanism}");
  462. }
  463. return JsonSerializer.Serialize(dict, ParamsJsonWriteOptions);
  464. }
  465. private static List<string> ParseCsvStates(string? csv)
  466. {
  467. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  468. return csv.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  469. .ToList();
  470. }
  471. private static string BuildThresholdDisplay(string mechanism, S8WizardFormV1 form, AdoS8MonitorMetric metric, string? unit)
  472. {
  473. var u = unit ?? string.Empty;
  474. if (mechanism == "DATE")
  475. {
  476. var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
  477. return grace > 0 ? $"超期 {grace} 分钟仍未完成触发" : "超期即触发";
  478. }
  479. if (mechanism == "RATIO")
  480. {
  481. var target = form.TargetRatio ?? metric.DefaultTargetRatio;
  482. return target.HasValue
  483. ? string.Format(CultureInfo.InvariantCulture, "低于 {0}% 触发", target.Value)
  484. : "低于目标比例触发";
  485. }
  486. if (mechanism == "VALUE_RANGE")
  487. {
  488. var lower = form.LowerBound ?? metric.DefaultLowerBound;
  489. var upper = form.UpperBound ?? metric.DefaultUpperBound;
  490. var segs = new List<string>();
  491. if (lower.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "低于 {0}{1}", lower.Value, u));
  492. if (upper.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "高于 {0}{1}", upper.Value, u));
  493. return segs.Count > 0 ? string.Join(" 或 ", segs) + " 触发" : "超出设定范围触发";
  494. }
  495. return string.Empty;
  496. }
  497. // ----- wizard_json 解析私有 DTO -----
  498. private sealed class S8WizardJsonV1
  499. {
  500. public int Version { get; set; }
  501. public int Step { get; set; }
  502. public S8WizardFormV1? Form { get; set; }
  503. public S8WizardLabelsV1? Labels { get; set; }
  504. }
  505. private sealed class S8WizardFormV1
  506. {
  507. public string? Mechanism { get; set; }
  508. public string? StageCode { get; set; }
  509. public string? OrderFlowCode { get; set; }
  510. public string? ExceptionTypeCode { get; set; }
  511. public string? Severity { get; set; }
  512. public string? ObjectType { get; set; }
  513. public string? ObjectLabel { get; set; }
  514. public string? MetricCode { get; set; }
  515. public string? MetricLabel { get; set; }
  516. public string? Unit { get; set; }
  517. public int? GraceMinutes { get; set; }
  518. // 前端 wizard_json 中 completedStates 是 CSV 字符串(如 "CLOSED,COMPLETED,DONE"),不是数组
  519. public string? CompletedStates { get; set; }
  520. public decimal? LowerBound { get; set; }
  521. public decimal? UpperBound { get; set; }
  522. public decimal? ToleranceAbs { get; set; }
  523. public decimal? ToleranceRatioPct { get; set; }
  524. public decimal? TargetRatio { get; set; }
  525. public string? RuleCode { get; set; }
  526. public int? PollIntervalSeconds { get; set; }
  527. public int? TriggerCountRequired { get; set; }
  528. public int? RecoverCountRequired { get; set; }
  529. }
  530. private sealed class S8WizardLabelsV1
  531. {
  532. public string? MechanismLabel { get; set; }
  533. public string? StageLabel { get; set; }
  534. public string? ObjectLabel { get; set; }
  535. public string? MetricLabel { get; set; }
  536. public string? ThresholdDisplay { get; set; }
  537. }
  538. }