S8ConfigDraftService.cs 31 KB

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