S8ConfigDraftService.cs 33 KB

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