S8ConfigDraftService.cs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  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. // CONFIG-WIZARD-T3K-ORDER-FLOW-STAGE-VALIDATE-1:销售订单链路阶段额外协议校验
  290. if (string.Equals(monitorObject.ObjectCode, "SALES_ORDER_FLOW_STAGE", StringComparison.Ordinal))
  291. {
  292. if (string.IsNullOrWhiteSpace(form.OrderFlowCode))
  293. throw new S8BizException("订单链路阶段规则必须选择订单流程节点");
  294. var ocf = NormalizeOrNull(metric.ObjectCodeField);
  295. if (!string.Equals(ocf, "order_code", StringComparison.OrdinalIgnoreCase))
  296. throw new S8BizException("订单链路阶段规则的 objectCodeField 必须为 order_code");
  297. }
  298. // 异常类型(baseline 0/0 + tenant 覆盖;enabled 必须 true;sceneCode 必须等于 stageCode)
  299. var exceptionType = await ResolveExceptionTypeAsync(draft.TenantId, draft.FactoryId, exceptionTypeCode);
  300. if (!string.IsNullOrWhiteSpace(exceptionType.SceneCode) && exceptionType.SceneCode != stageCode)
  301. throw new S8BizException("异常类型所属阶段与当前规则阶段不一致");
  302. // severity 优先 wizard_json,再 exceptionType.SeverityDefault,再 FOLLOW
  303. var severity = NormalizeOrNull(form.Severity)
  304. ?? NormalizeOrNull(exceptionType.SeverityDefault)
  305. ?? "FOLLOW";
  306. // dataSource:tenant/factory 下第一个 enabled
  307. var dataSource = await _dataSourceRep.AsQueryable()
  308. .Where(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.Enabled)
  309. .OrderBy(x => x.Id)
  310. .FirstAsync()
  311. ?? throw new S8BizException("未找到可用数据源");
  312. // scene_config:tenant/factory + stageCode + enabled(CreateAsync 也会校验,提前查给更清晰错误)
  313. var scene = await _sceneRep.AsQueryable()
  314. .FirstAsync(x => x.TenantId == draft.TenantId && x.FactoryId == draft.FactoryId && x.SceneCode == stageCode)
  315. ?? throw new S8BizException("规则所属场景不存在");
  316. if (!scene.Enabled) throw new S8BizException("规则所属场景未启用");
  317. // params_json + expression
  318. var paramsJson = BuildParamsJson(form, labels, mechanism, metric, monitorObject, exceptionTypeCode);
  319. var expression = BuildExpression(mechanism, monitorObject, metric, form);
  320. return new AdoS8WatchRule
  321. {
  322. TenantId = draft.TenantId,
  323. FactoryId = draft.FactoryId,
  324. RuleCode = ruleCode,
  325. SceneCode = stageCode,
  326. DataSourceId = dataSource.Id,
  327. WatchObjectType = monitorObject.ObjectType,
  328. RuleType = RuleTypeOf(mechanism),
  329. RuleMechanism = mechanism,
  330. StageCode = stageCode,
  331. OrderFlowCode = NormalizeOrNull(form.OrderFlowCode),
  332. SourceObjectType = monitorObject.ObjectType,
  333. Severity = severity,
  334. Expression = expression,
  335. ParamsJson = paramsJson,
  336. PollIntervalSeconds = form.PollIntervalSeconds is > 0 ? form.PollIntervalSeconds.Value : 300,
  337. TriggerCountRequired = form.TriggerCountRequired is > 0 ? form.TriggerCountRequired.Value : 1,
  338. RecoverCountRequired = form.RecoverCountRequired is > 0 ? form.RecoverCountRequired.Value : 1,
  339. ConsecutiveFailureCount = 0,
  340. Enabled = false,
  341. };
  342. }
  343. private async Task<AdoS8MonitorObject> ResolveMonitorObjectAsync(long tenantId, long factoryId, string objectType, string objectName)
  344. {
  345. var rows = await _monitorObjectRep.AsQueryable()
  346. .Where(x => x.ObjectType == objectType && x.ObjectName == objectName
  347. && ((x.TenantId == 0 && x.FactoryId == 0)
  348. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  349. .ToListAsync();
  350. var picked = rows
  351. .OrderByDescending(x => x.FactoryId)
  352. .ThenByDescending(x => x.TenantId)
  353. .FirstOrDefault();
  354. if (picked == null || !picked.Enabled)
  355. throw new S8BizException($"监控对象 '{objectName}' 不存在或未启用");
  356. return picked;
  357. }
  358. private async Task<AdoS8MonitorMetric> ResolveMonitorMetricAsync(long tenantId, long factoryId, string metricCode, string mechanism)
  359. {
  360. var rows = await _monitorMetricRep.AsQueryable()
  361. .Where(x => x.MetricCode == metricCode
  362. && ((x.TenantId == 0 && x.FactoryId == 0)
  363. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  364. .ToListAsync();
  365. var picked = rows
  366. .OrderByDescending(x => x.FactoryId)
  367. .ThenByDescending(x => x.TenantId)
  368. .FirstOrDefault();
  369. if (picked == null)
  370. throw new S8BizException($"监控指标 '{metricCode}' 不存在");
  371. if (!picked.Enabled)
  372. {
  373. var label = string.IsNullOrWhiteSpace(picked.MetricName) ? picked.MetricCode : picked.MetricName;
  374. throw new S8BizException($"监控指标 '{label}' 未启用,请先在监控指标字典中启用后再生成规则");
  375. }
  376. if (!string.IsNullOrWhiteSpace(picked.Mechanism) && picked.Mechanism != mechanism)
  377. throw new S8BizException("监控指标与报警机制不匹配");
  378. return picked;
  379. }
  380. private async Task<AdoS8ExceptionType> ResolveExceptionTypeAsync(long tenantId, long factoryId, string typeCode)
  381. {
  382. var rows = await _exceptionTypeRep.AsQueryable()
  383. .Where(x => x.TypeCode == typeCode
  384. && ((x.TenantId == 0 && x.FactoryId == 0)
  385. || (x.TenantId == tenantId && x.FactoryId == factoryId)))
  386. .ToListAsync();
  387. var picked = rows.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).FirstOrDefault();
  388. if (picked == null) throw new S8BizException($"异常类型 '{typeCode}' 不存在");
  389. if (!picked.Enabled) throw new S8BizException($"异常类型 '{picked.TypeName}' 未启用");
  390. return picked;
  391. }
  392. private static string RuleTypeOf(string mechanism) => mechanism switch
  393. {
  394. "DATE" => "TIMEOUT",
  395. "VALUE_RANGE" => "OUT_OF_RANGE",
  396. "RATIO" => "OUT_OF_RANGE",
  397. "MANUAL_REPORT" => throw new S8BizException("主动提报无需生成自动监控规则"),
  398. _ => throw new S8BizException($"不支持的报警机制:{mechanism}"),
  399. };
  400. // CONFIG-WIZARD-EXPRESSION-REAL-SQL-1:基于字典 source_table + 字段映射生成真实 SELECT。
  401. // 仅 DATE / VALUE_RANGE 走真实 SQL;source_table 为空时回退占位(规则仍 enabled=false,调度不会命中)。
  402. // 表名/列名走 IsSafeIdentifier 白名单校验,杜绝字典脏值导致的注入。
  403. private static readonly Regex SafeIdentifierPattern =
  404. new(@"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$", RegexOptions.Compiled);
  405. private static string ValidateSqlIdentifier(string raw, string what)
  406. {
  407. if (string.IsNullOrWhiteSpace(raw))
  408. throw new S8BizException($"字典字段缺失:{what}");
  409. var trimmed = raw.Trim();
  410. if (!SafeIdentifierPattern.IsMatch(trimmed))
  411. throw new S8BizException($"字典字段不合法:{what}={raw}");
  412. return trimmed;
  413. }
  414. private const string DatePlaceholderExpression =
  415. "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";
  416. private const string ValuePlaceholderExpression =
  417. "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";
  418. /// <summary>
  419. /// 含逻辑删除字段的源表追加过滤,避免规则启用后误命中已软删行。
  420. /// 当前仅 ic_item_stock 含 IsDeleted bit(1);其他表无 schema 元数据,不追加。
  421. /// </summary>
  422. private static string BuildSoftDeletePredicate(string? sourceTable)
  423. {
  424. if (string.IsNullOrWhiteSpace(sourceTable)) return string.Empty;
  425. if (sourceTable.Equals("ic_item_stock", StringComparison.OrdinalIgnoreCase))
  426. return " AND IsDeleted = 0";
  427. return string.Empty;
  428. }
  429. private static string BuildExpression(string mechanism, AdoS8MonitorObject monitorObject, AdoS8MonitorMetric metric, S8WizardFormV1 form)
  430. {
  431. var sourceTable = NormalizeOrNull(monitorObject.SourceTable);
  432. if (string.IsNullOrWhiteSpace(sourceTable))
  433. {
  434. // 字典中未配置 source_table;回退占位 SQL,规则仍默认 enabled=false。
  435. return mechanism == "DATE" ? DatePlaceholderExpression : ValuePlaceholderExpression;
  436. }
  437. var table = ValidateSqlIdentifier(sourceTable, "source_table");
  438. var idCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectIdField) ?? "id", "object_id_field");
  439. var codeCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectCodeField) ?? idCol, "object_code_field");
  440. var nameCol = ValidateSqlIdentifier(NormalizeOrNull(metric.ObjectNameField) ?? codeCol, "object_name_field");
  441. if (mechanism == "DATE")
  442. {
  443. var dueCol = ValidateSqlIdentifier(NormalizeOrNull(metric.DueAtField) ?? "due_at", "due_at_field");
  444. var statusCol = ValidateSqlIdentifier(NormalizeOrNull(metric.StatusField) ?? "status", "status_field");
  445. var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
  446. if (grace < 0) grace = 0;
  447. var graceClause = grace > 0
  448. ? $"DATE_SUB(NOW(), INTERVAL {grace} MINUTE)"
  449. : "NOW()";
  450. var states = ParseCsvStates(form.CompletedStates);
  451. if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
  452. var stateClause = string.Empty;
  453. if (states.Count > 0)
  454. {
  455. var quoted = string.Join(",", states.Select(s => "'" + s.Replace("'", "''") + "'"));
  456. stateClause = $" AND {statusCol} NOT IN ({quoted})";
  457. }
  458. var softDeleteClause = BuildSoftDeletePredicate(table);
  459. 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}{softDeleteClause}";
  460. }
  461. if (mechanism == "VALUE_RANGE")
  462. {
  463. var mvCol = ValidateSqlIdentifier(NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value", "measured_value_field");
  464. var lower = form.LowerBound ?? metric.DefaultLowerBound;
  465. var upper = form.UpperBound ?? metric.DefaultUpperBound;
  466. var preds = new List<string>();
  467. if (lower.HasValue) preds.Add($"{mvCol} < {lower.Value.ToString(CultureInfo.InvariantCulture)}");
  468. if (upper.HasValue) preds.Add($"{mvCol} > {upper.Value.ToString(CultureInfo.InvariantCulture)}");
  469. var oorClause = preds.Count > 0 ? " AND (" + string.Join(" OR ", preds) + ")" : string.Empty;
  470. var softDeleteClause = BuildSoftDeletePredicate(table);
  471. 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}{softDeleteClause}";
  472. }
  473. // RATIO 在上游已被拒绝(metric.Enabled=false 触发 400),此处兜底返回数值占位。
  474. return ValuePlaceholderExpression;
  475. }
  476. private static string BuildParamsJson(
  477. S8WizardFormV1 form,
  478. S8WizardLabelsV1? labels,
  479. string mechanism,
  480. AdoS8MonitorMetric metric,
  481. AdoS8MonitorObject monitorObject,
  482. string exceptionTypeCode)
  483. {
  484. var objectLabel = NormalizeOrNull(labels?.ObjectLabel)
  485. ?? NormalizeOrNull(form.ObjectLabel)
  486. ?? monitorObject.ObjectName;
  487. var metricLabel = NormalizeOrNull(labels?.MetricLabel)
  488. ?? NormalizeOrNull(form.MetricLabel)
  489. ?? metric.MetricName;
  490. var unit = NormalizeOrNull(form.Unit) ?? metric.Unit;
  491. var thresholdDisplay = NormalizeOrNull(labels?.ThresholdDisplay)
  492. ?? BuildThresholdDisplay(mechanism, form, metric, unit);
  493. var objectIdField = NormalizeOrNull(metric.ObjectIdField) ?? "source_object_id";
  494. var objectCodeField = NormalizeOrNull(metric.ObjectCodeField) ?? "related_object_code";
  495. var objectNameField = NormalizeOrNull(metric.ObjectNameField) ?? "related_object_name";
  496. var dict = new Dictionary<string, object?>
  497. {
  498. ["objectIdField"] = objectIdField,
  499. ["objectCodeField"] = objectCodeField,
  500. ["objectNameField"] = objectNameField,
  501. ["exceptionTypeCode"] = exceptionTypeCode,
  502. ["objectLabel"] = objectLabel,
  503. ["metricLabel"] = metricLabel,
  504. };
  505. if (mechanism == "DATE")
  506. {
  507. var states = ParseCsvStates(form.CompletedStates);
  508. if (states.Count == 0) states = ParseCsvStates(metric.DefaultCompletedStates);
  509. if (states.Count == 0) states = new List<string> { "CLOSED", "COMPLETED", "DONE" };
  510. var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
  511. dict["dueAtField"] = NormalizeOrNull(metric.DueAtField) ?? "due_at";
  512. dict["statusField"] = NormalizeOrNull(metric.StatusField) ?? "status";
  513. dict["completedStates"] = states;
  514. dict["graceMinutes"] = grace;
  515. dict["unit"] = unit ?? "分钟";
  516. dict["thresholdDisplay"] = thresholdDisplay;
  517. }
  518. else if (mechanism == "VALUE_RANGE")
  519. {
  520. var lower = form.LowerBound ?? metric.DefaultLowerBound;
  521. var upper = form.UpperBound ?? metric.DefaultUpperBound;
  522. if (lower == null && upper == null)
  523. throw new S8BizException("请配置上限或下限至少其一");
  524. dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
  525. dict["unit"] = unit;
  526. dict["thresholdDisplay"] = thresholdDisplay;
  527. if (lower.HasValue) dict["lowerBound"] = lower.Value;
  528. if (upper.HasValue) dict["upperBound"] = upper.Value;
  529. if (form.ToleranceAbs is > 0) dict["toleranceAbs"] = form.ToleranceAbs.Value;
  530. if (form.ToleranceRatioPct is > 0) dict["toleranceRatio"] = form.ToleranceRatioPct.Value / 100m;
  531. }
  532. else if (mechanism == "RATIO")
  533. {
  534. var target = form.TargetRatio ?? metric.DefaultTargetRatio;
  535. if (!target.HasValue) throw new S8BizException("请配置目标比例");
  536. dict["measuredValueField"] = NormalizeOrNull(metric.MeasuredValueField) ?? "measured_value";
  537. dict["lowerBound"] = target.Value;
  538. dict["unit"] = "%";
  539. dict["thresholdDisplay"] = thresholdDisplay;
  540. }
  541. else
  542. {
  543. throw new S8BizException($"不支持的报警机制:{mechanism}");
  544. }
  545. return JsonSerializer.Serialize(dict, ParamsJsonWriteOptions);
  546. }
  547. private static List<string> ParseCsvStates(string? csv)
  548. {
  549. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  550. return csv.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  551. .ToList();
  552. }
  553. private static string BuildThresholdDisplay(string mechanism, S8WizardFormV1 form, AdoS8MonitorMetric metric, string? unit)
  554. {
  555. var u = unit ?? string.Empty;
  556. if (mechanism == "DATE")
  557. {
  558. var grace = form.GraceMinutes ?? metric.DefaultGraceMinutes ?? 0;
  559. return grace > 0 ? $"超期 {grace} 分钟仍未完成触发" : "超期即触发";
  560. }
  561. if (mechanism == "RATIO")
  562. {
  563. var target = form.TargetRatio ?? metric.DefaultTargetRatio;
  564. return target.HasValue
  565. ? string.Format(CultureInfo.InvariantCulture, "低于 {0}% 触发", target.Value)
  566. : "低于目标比例触发";
  567. }
  568. if (mechanism == "VALUE_RANGE")
  569. {
  570. var lower = form.LowerBound ?? metric.DefaultLowerBound;
  571. var upper = form.UpperBound ?? metric.DefaultUpperBound;
  572. var segs = new List<string>();
  573. if (lower.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "低于 {0}{1}", lower.Value, u));
  574. if (upper.HasValue) segs.Add(string.Format(CultureInfo.InvariantCulture, "高于 {0}{1}", upper.Value, u));
  575. return segs.Count > 0 ? string.Join(" 或 ", segs) + " 触发" : "超出设定范围触发";
  576. }
  577. return string.Empty;
  578. }
  579. // ----- wizard_json 解析私有 DTO -----
  580. private sealed class S8WizardJsonV1
  581. {
  582. public int Version { get; set; }
  583. public int Step { get; set; }
  584. public S8WizardFormV1? Form { get; set; }
  585. public S8WizardLabelsV1? Labels { get; set; }
  586. }
  587. private sealed class S8WizardFormV1
  588. {
  589. public string? Mechanism { get; set; }
  590. public string? StageCode { get; set; }
  591. public string? OrderFlowCode { get; set; }
  592. public string? ExceptionTypeCode { get; set; }
  593. public string? Severity { get; set; }
  594. public string? ObjectType { get; set; }
  595. public string? ObjectLabel { get; set; }
  596. public string? MetricCode { get; set; }
  597. public string? MetricLabel { get; set; }
  598. public string? Unit { get; set; }
  599. public int? GraceMinutes { get; set; }
  600. // 前端 wizard_json 中 completedStates 是 CSV 字符串(如 "CLOSED,COMPLETED,DONE"),不是数组
  601. public string? CompletedStates { get; set; }
  602. public decimal? LowerBound { get; set; }
  603. public decimal? UpperBound { get; set; }
  604. public decimal? ToleranceAbs { get; set; }
  605. public decimal? ToleranceRatioPct { get; set; }
  606. public decimal? TargetRatio { get; set; }
  607. public string? RuleCode { get; set; }
  608. public int? PollIntervalSeconds { get; set; }
  609. public int? TriggerCountRequired { get; set; }
  610. public int? RecoverCountRequired { get; set; }
  611. }
  612. private sealed class S8WizardLabelsV1
  613. {
  614. public string? MechanismLabel { get; set; }
  615. public string? StageLabel { get; set; }
  616. public string? ObjectLabel { get; set; }
  617. public string? MetricLabel { get; set; }
  618. public string? ThresholdDisplay { get; set; }
  619. }
  620. }