S8ConfigDraftService.cs 33 KB

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