S8NotificationLayerResolver.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. using Admin.NET.Plugin.ApprovalFlow.Service;
  5. using Microsoft.Extensions.Logging;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  7. /// <summary>
  8. /// S8 通知分层路由解析器(S8-NOTIFY-LAYER-RESOLVE-1)
  9. ///
  10. /// 职责链:
  11. /// (tenantId, factoryId, sceneCode, severity)
  12. /// → 命中 ado_s8_notification_layer 行(多行 OK)
  13. /// → 解析 target_role_ids(dual-format)
  14. /// → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔)
  15. /// → S8RoleResolver → userIds
  16. /// → S8NotificationPushAdapter.PushAsync
  17. /// → 由 PushAdapter 落 AdoS8NotificationLog(**每个「已注册且被选中」的渠道**一条)
  18. ///
  19. /// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
  20. /// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
  21. ///
  22. /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:平台默认回落粒度 = **(scene, severity, level) 逐层**。
  23. /// 一次 OR 查询取回「平台默认 (0,0) ∪ 本工厂」的同 scene+severity 行,再交
  24. /// <see cref="S8NotificationLayerMerge.Effective"/> 逐 level 取工厂优先。
  25. /// 修正前是整键 fallback(精确租户查到任意一行即完全不读 (0,0)),会导致工厂补一层就丢掉平台其余层级。
  26. /// merge 与配置页 ListAsync 共用同一实现,禁止各自复制。
  27. /// </summary>
  28. public class S8NotificationLayerResolver : ITransient
  29. {
  30. private readonly SqlSugarRepository<AdoS8NotificationLayer> _layerRep;
  31. private readonly S8RoleResolver _roleResolver;
  32. private readonly S8NotificationPushAdapter _pushAdapter;
  33. private readonly ILogger<S8NotificationLayerResolver> _logger;
  34. public S8NotificationLayerResolver(
  35. SqlSugarRepository<AdoS8NotificationLayer> layerRep,
  36. S8RoleResolver roleResolver,
  37. S8NotificationPushAdapter pushAdapter,
  38. ILogger<S8NotificationLayerResolver> logger)
  39. {
  40. _layerRep = layerRep;
  41. _roleResolver = roleResolver;
  42. _pushAdapter = pushAdapter;
  43. _logger = logger;
  44. }
  45. public class DispatchByLayerInput
  46. {
  47. public long TenantId { get; set; }
  48. public long FactoryId { get; set; }
  49. public long? ExceptionId { get; set; }
  50. public string? ExceptionNo { get; set; }
  51. public string SceneCode { get; set; } = string.Empty;
  52. public string Severity { get; set; } = string.Empty;
  53. public string Title { get; set; } = string.Empty;
  54. public string Content { get; set; } = string.Empty;
  55. public string? Status { get; set; }
  56. public string? SourceRuleCode { get; set; }
  57. public string? JumpUrl { get; set; }
  58. /// <summary>
  59. /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
  60. /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
  61. /// </summary>
  62. public bool Recovered { get; set; }
  63. // ============================================================
  64. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
  65. // 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
  66. // ============================================================
  67. public int? RepeatCount30d { get; set; }
  68. public decimal? CumulativeLossHours30d { get; set; }
  69. public string? SuggestedAttentionLevel { get; set; }
  70. public string? SuggestedAttentionLabel { get; set; }
  71. public string? ImpactReason { get; set; }
  72. // ============================================================
  73. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
  74. // 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
  75. // ============================================================
  76. public bool? OverdueClosed { get; set; }
  77. public DateTime? ClosedAt { get; set; }
  78. public DateTime? SlaDeadlineRef { get; set; }
  79. public decimal? OverdueCloseHours { get; set; }
  80. }
  81. /// <summary>
  82. /// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
  83. /// </summary>
  84. public async Task DispatchByLayerAsync(DispatchByLayerInput input)
  85. {
  86. if (input == null)
  87. {
  88. _logger.LogWarning("S8LayerDispatch: input null");
  89. return;
  90. }
  91. if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
  92. {
  93. _logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
  94. return;
  95. }
  96. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
  97. //
  98. // 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
  99. // 那意味着工厂只要为某一层建一行,平台默认在该 scene+severity 下的其余层级会整体消失——
  100. // 工厂本意是「补一层」,实际是「换掉整组」,且该副作用在配置页上不可见。
  101. //
  102. // 现在一次 OR 查询同时取回平台默认与本工厂行,再由 S8NotificationLayerMerge 逐 level 取舍。
  103. // merge 与配置页 ListAsync 共用同一函数,杜绝「页面显示一种、运行时派发另一种」。
  104. List<AdoS8NotificationLayer> layers;
  105. var severity = S8SeverityCode.Normalize(input.Severity);
  106. try
  107. {
  108. var candidates = await _layerRep.AsQueryable()
  109. .Where(x => x.SceneCode == input.SceneCode && x.Severity == severity)
  110. .Where(x => (x.TenantId == input.TenantId && x.FactoryId == input.FactoryId)
  111. || (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId))
  112. .ToListAsync();
  113. layers = S8NotificationLayerMerge.Effective(candidates);
  114. }
  115. catch (Exception ex)
  116. {
  117. _logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity);
  118. return;
  119. }
  120. if (layers.Count > 0)
  121. _logger.LogInformation(
  122. "S8LayerDispatch: {Total} effective layer rows across {Levels} level(s) for scene={Scene} sev={Sev}; {G} from platform default (tenant=0/factory=0)",
  123. layers.Count,
  124. layers.Select(x => S8NotificationLayerMerge.NormalizeLevel(x.LevelCode)).Distinct().Count(),
  125. input.SceneCode, severity,
  126. layers.Count(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)));
  127. if (layers.Count == 0)
  128. {
  129. _logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
  130. input.TenantId, input.FactoryId, input.SceneCode, input.Severity);
  131. return;
  132. }
  133. var notification = BuildNotification(input);
  134. foreach (var layer in layers)
  135. {
  136. List<long> userIds;
  137. try
  138. {
  139. var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds);
  140. if (tokens.Count == 0)
  141. {
  142. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
  143. continue;
  144. }
  145. userIds = await _roleResolver.ResolveUserIdsAsync(tokens);
  146. }
  147. catch (Exception ex)
  148. {
  149. _logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id);
  150. continue;
  151. }
  152. if (userIds.Count == 0)
  153. {
  154. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'",
  155. layer.Id, layer.TargetRoleIds);
  156. continue;
  157. }
  158. var channels = ParseChannels(layer.NotifyChannel);
  159. try
  160. {
  161. await _pushAdapter.PushAsync(
  162. tenantId: input.TenantId,
  163. factoryId: input.FactoryId,
  164. exceptionId: input.ExceptionId,
  165. userIds: userIds,
  166. notification: notification,
  167. channels: channels);
  168. }
  169. catch (Exception ex)
  170. {
  171. // PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
  172. _logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
  173. }
  174. }
  175. }
  176. private static FlowNotification BuildNotification(DispatchByLayerInput input)
  177. {
  178. // 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
  179. // InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
  180. var ctx = new Dictionary<string, string?>
  181. {
  182. ["exceptionId"] = input.ExceptionId?.ToString(),
  183. ["exceptionNo"] = input.ExceptionNo,
  184. ["sceneCode"] = input.SceneCode,
  185. ["severity"] = input.Severity,
  186. ["status"] = input.Status,
  187. ["sourceRuleCode"] = input.SourceRuleCode,
  188. ["jumpUrl"] = input.JumpUrl,
  189. };
  190. if (input.Recovered) ctx["recovered"] = "true";
  191. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
  192. if (input.RepeatCount30d.HasValue)
  193. ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
  194. if (input.CumulativeLossHours30d.HasValue)
  195. ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  196. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
  197. ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
  198. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
  199. ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
  200. if (!string.IsNullOrWhiteSpace(input.ImpactReason))
  201. ctx["impactReason"] = input.ImpactReason;
  202. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
  203. if (input.OverdueClosed == true)
  204. ctx["overdueClosed"] = "true";
  205. if (input.ClosedAt.HasValue)
  206. ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  207. if (input.SlaDeadlineRef.HasValue)
  208. ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  209. if (input.OverdueCloseHours.HasValue)
  210. ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  211. return new FlowNotification
  212. {
  213. Type = FlowNotificationTypeEnum.NewTask,
  214. BizType = "S8_EXCEPTION",
  215. InstanceId = input.ExceptionId ?? 0,
  216. Title = input.Title ?? string.Empty,
  217. Content = input.Content ?? string.Empty,
  218. Context = ctx,
  219. };
  220. }
  221. public static List<string> ParseChannels(string? csv)
  222. {
  223. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  224. return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  225. }
  226. }