S8NotificationLayerResolver.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  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 IS8NotificationRecipientResolver _recipientResolver;
  33. private readonly Admin.NET.Core.Service.SysNoticeService _noticeService;
  34. private readonly S8NotificationPushAdapter _pushAdapter;
  35. private readonly ILogger<S8NotificationLayerResolver> _logger;
  36. public S8NotificationLayerResolver(
  37. SqlSugarRepository<AdoS8NotificationLayer> layerRep,
  38. S8RoleResolver roleResolver,
  39. IS8NotificationRecipientResolver recipientResolver,
  40. Admin.NET.Core.Service.SysNoticeService noticeService,
  41. S8NotificationPushAdapter pushAdapter,
  42. ILogger<S8NotificationLayerResolver> logger)
  43. {
  44. _layerRep = layerRep;
  45. _roleResolver = roleResolver;
  46. _recipientResolver = recipientResolver;
  47. _noticeService = noticeService;
  48. _pushAdapter = pushAdapter;
  49. _logger = logger;
  50. }
  51. public class DispatchByLayerInput
  52. {
  53. public long TenantId { get; set; }
  54. public long? ExceptionId { get; set; }
  55. public string? ExceptionNo { get; set; }
  56. public string SceneCode { get; set; } = string.Empty;
  57. public string Severity { get; set; } = string.Empty;
  58. public string Title { get; set; } = string.Empty;
  59. public string Content { get; set; } = string.Empty;
  60. public string? Status { get; set; }
  61. public string? SourceRuleCode { get; set; }
  62. /// <summary>
  63. /// S8-NOTIFY-RECIPIENT-1:本次派发对应的事件码(<c>S8NotifyEventCode</c>)。
  64. /// <para>为空表示调用方尚未接入新收件人模型,本次直接走 LEGACY 分层。</para>
  65. /// </summary>
  66. public string? EventCode { get; set; }
  67. /// <summary>
  68. /// 触发本次派发的异常单本体。新模型要靠它解析 ASSIGNEE / REVIEWER / HANDLER_POOL。
  69. /// 为 null 时同样退回 LEGACY 分层。
  70. /// </summary>
  71. public Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8Exception? ExceptionRef { get; set; }
  72. public string? JumpUrl { get; set; }
  73. /// <summary>
  74. /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
  75. /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
  76. /// </summary>
  77. public bool Recovered { get; set; }
  78. // ============================================================
  79. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
  80. // 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
  81. // ============================================================
  82. public int? RepeatCount30d { get; set; }
  83. public decimal? CumulativeLossHours30d { get; set; }
  84. public string? SuggestedAttentionLevel { get; set; }
  85. public string? SuggestedAttentionLabel { get; set; }
  86. public string? ImpactReason { get; set; }
  87. // ============================================================
  88. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
  89. // 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
  90. // ============================================================
  91. public bool? OverdueClosed { get; set; }
  92. public DateTime? ClosedAt { get; set; }
  93. public DateTime? SlaDeadlineRef { get; set; }
  94. public decimal? OverdueCloseHours { get; set; }
  95. }
  96. /// <summary>
  97. /// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
  98. /// </summary>
  99. public async Task DispatchByLayerAsync(DispatchByLayerInput input)
  100. {
  101. if (input == null)
  102. {
  103. _logger.LogWarning("S8LayerDispatch: input null");
  104. return;
  105. }
  106. if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
  107. {
  108. _logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
  109. return;
  110. }
  111. // ── S8-NOTIFY-RECIPIENT-1:新收件人模型是 CURRENT AUTHORITY ──
  112. //
  113. // 新模型对该 (租户, 规则, 事件) 一旦有任何配置,旧分层表就**完全不参与**本次派发。
  114. // 不做"两者合并":合并会让管理员删掉收件人之后通知照发,而他在新页面上
  115. // 已经看不到那些人了 —— 那是最难自查的一类配置故障。
  116. //
  117. // 只有新模型没有配置时才回落 LEGACY 分层,保证现网既有通知不被这批改动打断。
  118. if (!string.IsNullOrWhiteSpace(input.EventCode) && input.ExceptionRef != null)
  119. {
  120. var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef);
  121. if (resolution.Source != "NONE")
  122. {
  123. await DispatchToUsersAsync(input, resolution);
  124. return;
  125. }
  126. }
  127. // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
  128. //
  129. // 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
  130. // 那意味着工厂只要为某一层建一行,平台默认在该 scene+severity 下的其余层级会整体消失——
  131. // 工厂本意是「补一层」,实际是「换掉整组」,且该副作用在配置页上不可见。
  132. //
  133. // 现在一次 OR 查询同时取回平台默认与本工厂行,再由 S8NotificationLayerMerge 逐 level 取舍。
  134. // merge 与配置页 ListAsync 共用同一函数,杜绝「页面显示一种、运行时派发另一种」。
  135. List<AdoS8NotificationLayer> layers;
  136. var severity = S8SeverityCode.Normalize(input.Severity);
  137. try
  138. {
  139. var candidates = await _layerRep.AsQueryable()
  140. .Where(x => x.SceneCode == input.SceneCode && x.Severity == severity)
  141. // S8-TENANT-ONLY-BATCH6:候选判据去掉 factory 等值,保留 (0,0) 平台默认哨兵。
  142. // 不去掉是必然断链:本批之后异常 factory_id 恒为 0,而库里的 notification_layer
  143. // 租户行 factory_id ≠ 0,租户那一支永远命中不了 —— 分层通知会静默退化成"只有平台默认"。
  144. .Where(x => x.TenantId == input.TenantId
  145. || (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId))
  146. .ToListAsync();
  147. layers = S8NotificationLayerMerge.Effective(candidates);
  148. }
  149. catch (Exception ex)
  150. {
  151. _logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity);
  152. return;
  153. }
  154. if (layers.Count > 0)
  155. _logger.LogInformation(
  156. "S8LayerDispatch: {Total} effective layer rows across {Levels} level(s) for scene={Scene} sev={Sev}; {G} from platform default (tenant=0/factory=0)",
  157. layers.Count,
  158. layers.Select(x => S8NotificationLayerMerge.NormalizeLevel(x.LevelCode)).Distinct().Count(),
  159. input.SceneCode, severity,
  160. layers.Count(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)));
  161. if (layers.Count == 0)
  162. {
  163. _logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
  164. input.TenantId, input.SceneCode, input.Severity);
  165. return;
  166. }
  167. var notification = BuildNotification(input);
  168. foreach (var layer in layers)
  169. {
  170. List<long> userIds;
  171. try
  172. {
  173. var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds);
  174. if (tokens.Count == 0)
  175. {
  176. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
  177. continue;
  178. }
  179. // S8-ROLE-TENANT-SCOPE-1:显式透传租户作为安全边界。
  180. // 注意用 input.TenantId 而非 layer.TenantId —— 全局层(tenant_id=0)也必须
  181. // 按**触发异常的租户**解析收件人,否则全局层会解析不到任何角色。
  182. userIds = await _roleResolver.ResolveUserIdsAsync(input.TenantId, tokens);
  183. }
  184. catch (Exception ex)
  185. {
  186. _logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id);
  187. continue;
  188. }
  189. if (userIds.Count == 0)
  190. {
  191. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'",
  192. layer.Id, layer.TargetRoleIds);
  193. continue;
  194. }
  195. var channels = ParseChannels(layer.NotifyChannel);
  196. try
  197. {
  198. await _pushAdapter.PushAsync(
  199. tenantId: input.TenantId,
  200. // 通知日志的 factory 列写兼容值 0:归属只由 tenant 决定。
  201. factoryId: S8ConfigScope.GlobalFactoryId,
  202. exceptionId: input.ExceptionId,
  203. userIds: userIds,
  204. notification: notification,
  205. channels: channels);
  206. }
  207. catch (Exception ex)
  208. {
  209. // PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
  210. _logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
  211. }
  212. }
  213. }
  214. /// <summary>
  215. /// 新收件人模型的投递。复用与 LEGACY 完全相同的 <see cref="BuildNotification"/> 与
  216. /// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致,
  217. /// 否则同一条异常会因为配置来源不同而呈现出不同的消息。
  218. /// </summary>
  219. private async Task DispatchToUsersAsync(DispatchByLayerInput input, S8RecipientResolution resolution)
  220. {
  221. if (resolution.UserIds.Count == 0)
  222. {
  223. // 0 收件人必须可观测:配了却发不出去,与"没配置"是两回事。
  224. // 绝不静默返回让调用方以为发送成功 —— 那正是本模块反复出现的失败形态。
  225. _logger.LogWarning(
  226. "S8RecipientDispatch: resolved 0 recipients (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
  227. input.TenantId, input.EventCode, input.ExceptionId,
  228. string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
  229. return;
  230. }
  231. _logger.LogInformation(
  232. "S8RecipientDispatch: source={Source} recipients={Count} (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
  233. resolution.Source, resolution.UserIds.Count, input.TenantId, input.EventCode, input.ExceptionId,
  234. string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
  235. // ① 站内信:这是本部署里**唯一会为用户留下一条可见消息**的通道。
  236. //
  237. // 已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms —— 没有站内信。
  238. // 旧的分层派发因此从来只推 SignalR(用户不在线就等于没发过),
  239. // 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
  240. // 新模型把两者合并:所有事件都先落站内信,再推实时通道。
  241. // 少了这一步,本次重构会静默丢掉认领 / 转派原本有的站内信(本地实测已发生)。
  242. try
  243. {
  244. await _noticeService.PublishToUsersAsync(
  245. input.Title,
  246. BuildNoticeContent(input),
  247. resolution.UserIds.ToArray(),
  248. 0,
  249. "S8异常监控");
  250. }
  251. catch (Exception ex)
  252. {
  253. _logger.LogWarning(ex, "S8RecipientDispatch: notice publish throw (event={Event})", input.EventCode);
  254. }
  255. // ② 实时通道:在线用户即时可见;不在线也不影响①已经留下的站内信。
  256. try
  257. {
  258. await _pushAdapter.PushAsync(
  259. tenantId: input.TenantId,
  260. factoryId: S8ConfigScope.GlobalFactoryId,
  261. exceptionId: input.ExceptionId,
  262. userIds: resolution.UserIds,
  263. notification: BuildNotification(input),
  264. channels: DefaultChannels);
  265. }
  266. catch (Exception ex)
  267. {
  268. _logger.LogWarning(ex, "S8RecipientDispatch: push throw (event={Event})", input.EventCode);
  269. }
  270. }
  271. /// <summary>站内信正文:内容 + 一个能点回异常详情的链接。</summary>
  272. private static string BuildNoticeContent(DispatchByLayerInput input)
  273. {
  274. var jump = string.IsNullOrWhiteSpace(input.JumpUrl) ? null : $"/#{input.JumpUrl}";
  275. return $"<p>{input.ExceptionNo} {input.Content}</p>"
  276. + (jump == null ? string.Empty : $"<p><a href=\"{jump}\">查看异常详情</a></p>");
  277. }
  278. /// <summary>
  279. /// 新模型的投递渠道。只列<b>真实可用</b>的两个 —— 站内信与 SignalR,
  280. /// 与旧分层表里实际在用的 <c>notify_channel='log,SignalR'</c> 同源。
  281. /// 不为了 UI 看起来完整而虚构短信 / 邮件之类当前根本没有实现的渠道。
  282. /// </summary>
  283. private static readonly List<string> DefaultChannels = new() { "log", "SignalR" };
  284. private static FlowNotification BuildNotification(DispatchByLayerInput input)
  285. {
  286. // 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
  287. // InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
  288. var ctx = new Dictionary<string, string?>
  289. {
  290. ["exceptionId"] = input.ExceptionId?.ToString(),
  291. ["exceptionNo"] = input.ExceptionNo,
  292. ["sceneCode"] = input.SceneCode,
  293. ["severity"] = input.Severity,
  294. ["status"] = input.Status,
  295. ["sourceRuleCode"] = input.SourceRuleCode,
  296. ["jumpUrl"] = input.JumpUrl,
  297. };
  298. if (input.Recovered) ctx["recovered"] = "true";
  299. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
  300. if (input.RepeatCount30d.HasValue)
  301. ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
  302. if (input.CumulativeLossHours30d.HasValue)
  303. ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  304. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
  305. ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
  306. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
  307. ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
  308. if (!string.IsNullOrWhiteSpace(input.ImpactReason))
  309. ctx["impactReason"] = input.ImpactReason;
  310. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
  311. if (input.OverdueClosed == true)
  312. ctx["overdueClosed"] = "true";
  313. if (input.ClosedAt.HasValue)
  314. ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  315. if (input.SlaDeadlineRef.HasValue)
  316. ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  317. if (input.OverdueCloseHours.HasValue)
  318. ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  319. return new FlowNotification
  320. {
  321. Type = FlowNotificationTypeEnum.NewTask,
  322. BizType = "S8_EXCEPTION",
  323. InstanceId = input.ExceptionId ?? 0,
  324. Title = input.Title ?? string.Empty,
  325. Content = input.Content ?? string.Empty,
  326. Context = ctx,
  327. };
  328. }
  329. public static List<string> ParseChannels(string? csv)
  330. {
  331. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  332. return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  333. }
  334. }