S8NotificationLayerResolver.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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-LEGACY-NOTIFY-DECOMMISSION-1:**LEGACY 分层已从 Runtime 退役。**
  23. /// 本类不再读取 <c>ado_s8_notification_layer</c>,收件人唯一来源是
  24. /// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType)。
  25. /// 类名保留 "Layer" 只为不牵动全部调用方;它现在是<b>通知派发入口</b>,与分层无关。
  26. /// 旧表 / Entity / CRUD / Seed 类仍存在但已无 Runtime 消费者,物理清理见 P1-C3。
  27. /// </summary>
  28. public class S8NotificationLayerResolver : ITransient
  29. {
  30. private readonly IS8NotificationRecipientResolver _recipientResolver;
  31. private readonly Admin.NET.Core.Service.SysNoticeService _noticeService;
  32. private readonly S8NotificationPushAdapter _pushAdapter;
  33. private readonly ILogger<S8NotificationLayerResolver> _logger;
  34. public S8NotificationLayerResolver(
  35. IS8NotificationRecipientResolver recipientResolver,
  36. Admin.NET.Core.Service.SysNoticeService noticeService,
  37. S8NotificationPushAdapter pushAdapter,
  38. ILogger<S8NotificationLayerResolver> logger)
  39. {
  40. _recipientResolver = recipientResolver;
  41. _noticeService = noticeService;
  42. _pushAdapter = pushAdapter;
  43. _logger = logger;
  44. }
  45. public class DispatchByLayerInput
  46. {
  47. public long TenantId { get; set; }
  48. public long? ExceptionId { get; set; }
  49. public string? ExceptionNo { get; set; }
  50. public string SceneCode { get; set; } = string.Empty;
  51. public string Severity { get; set; } = string.Empty;
  52. public string Title { get; set; } = string.Empty;
  53. public string Content { get; set; } = string.Empty;
  54. public string? Status { get; set; }
  55. public string? SourceRuleCode { get; set; }
  56. /// <summary>
  57. /// S8-NOTIFY-RECIPIENT-1:本次派发对应的事件码(<c>S8NotifyEventCode</c>)。
  58. /// <para>为空表示调用方尚未接入新收件人模型,本次直接走 LEGACY 分层。</para>
  59. /// </summary>
  60. public string? EventCode { get; set; }
  61. /// <summary>
  62. /// 触发本次派发的异常单本体。新模型要靠它解析 ASSIGNEE / REVIEWER / HANDLER_POOL。
  63. /// 为 null 时同样退回 LEGACY 分层。
  64. /// </summary>
  65. public Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8Exception? ExceptionRef { get; set; }
  66. public string? JumpUrl { get; set; }
  67. /// <summary>
  68. /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
  69. /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
  70. /// </summary>
  71. public bool Recovered { get; set; }
  72. // ============================================================
  73. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
  74. // 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
  75. // ============================================================
  76. public int? RepeatCount30d { get; set; }
  77. public decimal? CumulativeLossHours30d { get; set; }
  78. public string? SuggestedAttentionLevel { get; set; }
  79. public string? SuggestedAttentionLabel { get; set; }
  80. public string? ImpactReason { get; set; }
  81. // ============================================================
  82. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
  83. // 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
  84. // ============================================================
  85. public bool? OverdueClosed { get; set; }
  86. public DateTime? ClosedAt { get; set; }
  87. public DateTime? SlaDeadlineRef { get; set; }
  88. public decimal? OverdueCloseHours { get; set; }
  89. }
  90. /// <summary>
  91. /// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
  92. /// </summary>
  93. public async Task DispatchByLayerAsync(DispatchByLayerInput input)
  94. {
  95. if (input == null)
  96. {
  97. _logger.LogWarning("S8LayerDispatch: input null");
  98. return;
  99. }
  100. if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
  101. {
  102. _logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
  103. return;
  104. }
  105. // ── S8-LEGACY-NOTIFY-DECOMMISSION-1:LEGACY 分层已从 Runtime 彻底退役 ──
  106. //
  107. // **本方法不再读取 ado_s8_notification_layer。** 通知收件人的唯一来源是
  108. // Rule-centric 新模型(ado_s8_notification_recipient):
  109. // Event → RecipientType → 责任池 / 当前处理人 / 当前复核人 → SysUser
  110. //
  111. // 为什么整段删掉而不是留个开关:旧链路按 (scene, severity, level) 选人,
  112. // 与新模型的 (rule, event, responsibility) 是两套互不相干的维度。留着它,
  113. // 「新模型没配」就会静默走另一套语义,而管理员在规则弹窗里根本看不到那批人 ——
  114. // 这正是本模块反复出现的失败形态。退役后语义变成:**没配就是没配**,
  115. // 缺口会以 0 收件人的形式暴露出来,而不是被旧配置悄悄兜住。
  116. //
  117. // 刻意<b>不动</b>既有的 fail-loud 语义:新模型查到配置但解析出 0 人时,
  118. // 仍由 DispatchToUsersAsync 落 Warning 后返回,不做任何回落。
  119. //
  120. // 已知缺口(本批明确不修,见 P0-C2 回执):人工主动提报的异常
  121. // SourceRuleCode 为 NULL,新模型会以 rule_code='*' 为键查找,而 '*' 目前
  122. // 没有任何业务写入路径 —— 这类异常的后续事件通知暂时无收件人来源。
  123. // 该缺口由「主动提报通知契约」独立梳理,**不以恢复 LEGACY 的方式兜底**。
  124. if (string.IsNullOrWhiteSpace(input.EventCode) || input.ExceptionRef == null)
  125. {
  126. _logger.LogInformation(
  127. "S8NotifyDispatch skip: 缺少 eventCode 或 exception 引用,无法解析新模型收件人 (exceptionId={ExceptionId} event={Event})",
  128. input.ExceptionId, input.EventCode);
  129. return;
  130. }
  131. var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef);
  132. if (resolution.Source == "NONE")
  133. {
  134. // 没有配置就是没有配置 —— 不再有第二套语义接管。
  135. _logger.LogInformation(
  136. "S8NotifyDispatch skip: 该 (租户,规则,事件) 未配置收件人,不发送 (tenant={Tenant} rule={Rule} event={Event} exceptionId={ExceptionId})",
  137. input.TenantId, input.ExceptionRef.SourceRuleCode, input.EventCode, input.ExceptionId);
  138. return;
  139. }
  140. await DispatchToUsersAsync(input, resolution);
  141. }
  142. /// <summary>
  143. /// S8-NOTIFY-EXPLICIT-1:把通知投递给<b>调用方显式给定</b>的账号,**跳过收件人配置解析**。
  144. ///
  145. /// <para><b>为什么这条路径不需要配置行</b>:<see cref="DispatchByLayerAsync"/> 解决的是
  146. /// 「这类事件该通知谁」——答案要从 <c>ado_s8_notification_recipient</c> 的
  147. /// (租户, 规则, 事件) 配置推导,所以没配置就必须停在 <c>Source == "NONE"</c> 的门上。
  148. /// 本方法解决的是另一个问题:收件人在调用现场<b>已经是确定的具体账号</b>
  149. /// (由业务动作本身决定,例如建单时手选的对象),不存在"该通知谁"的推导空间。
  150. /// 对这种输入再去查一遍配置表只有一种结果:配置缺失时把一条本该送达的通知丢掉——
  151. /// 即"人已经指名道姓选好了,却因为管理员没配规则而没收到"。
  152. /// 因此这里刻意<b>不经过</b> <c>_recipientResolver.ResolveAsync</c>,
  153. /// 也就不会被 <c>Source == "NONE"</c> 提前拦截。</para>
  154. ///
  155. /// <para><b>为什么不在通知层重做身份校验</b>:账号是否存在、是否启用、是否属于本租户,
  156. /// 是<b>调用方</b>的前置(<c>IS8UserScopeValidator.EnsureActiveUserAsync</c>)——
  157. /// 收件人本来就是它校验通过后才交到这里的。通知层若再造一套判断,就等于在系统里
  158. /// 存在第二个身份 Authority:两处规则迟早不一致,而不一致的那一刻没有任何报错,
  159. /// 只表现为"某些人莫名其妙收不到通知"。所以本方法<b>只负责投递</b>,
  160. /// 不做账号有效性 / 租户归属判断,也不因此放宽任何边界——
  161. /// 边界仍在调用方,且调用方必须先校验再调本方法。</para>
  162. ///
  163. /// <para>本批只提供能力,<b>不接任何具体事件</b>;
  164. /// <c>EXCEPTION_TRANSFERRED</c> / <c>VERIFICATION_SUBMITTED</c> 等接线登记为 Deferred。</para>
  165. /// </summary>
  166. /// <param name="input">通知载荷,与配置路径同一个入参类型,保证两条路径构造出的消息完全一致。</param>
  167. /// <param name="userIds">显式收件账号(<c>SysUser.Id</c>)。允许重复,内部去重。</param>
  168. public async Task DispatchToExplicitUsersAsync(DispatchByLayerInput input, IReadOnlyCollection<long> userIds)
  169. {
  170. // 前置与 DispatchByLayerAsync 保持一致:入参本身缺失属于调用方 bug,落 Warning。
  171. if (input == null)
  172. {
  173. _logger.LogWarning("S8ExplicitDispatch: input null");
  174. return;
  175. }
  176. // ExceptionRef 同样沿用配置路径的前置。本方法虽不靠它解析收件人,
  177. // 但它是"这条通知挂在哪张单据上"的唯一凭据,缺了就无法在排障时定位,
  178. // 也说明调用现场根本没拿到单据本体——那种状态下不该发通知。
  179. if (input.ExceptionRef == null)
  180. {
  181. _logger.LogInformation(
  182. "S8ExplicitDispatch skip: 缺少 exception 引用,不发送 (exceptionId={ExceptionId} event={Event})",
  183. input.ExceptionId, input.EventCode);
  184. return;
  185. }
  186. // 刻意**不**沿用 sceneCode / severity 的前置:那一条挡的是配置路径的
  187. // **路由键**(要靠它们选人)。本路径收件人已给定,两者只是载荷字段,
  188. // 缺失不该让一条指名道姓的通知静默消失。
  189. // 去重;同时剔除非正数(0 / 负数不是合法 SysUser.Id,多半是调用方没取到值)。
  190. var distinct = (userIds ?? Array.Empty<long>())
  191. .Where(id => id > 0)
  192. .Distinct()
  193. .ToList();
  194. if (distinct.Count == 0)
  195. {
  196. // 明确 no-op 而不是静默返回:调用方以为发出去了、实际一个人都没发,
  197. // 是本模块反复出现的失败形态,必须留下可检索的记录。
  198. _logger.LogInformation(
  199. "S8ExplicitDispatch skip: 显式收件人为空(原始 {Raw} 个,去重且过滤非法 Id 后为 0),不发送 (tenant={Tenant} event={Event} exceptionId={ExceptionId})",
  200. userIds?.Count ?? 0, input.TenantId, input.EventCode, input.ExceptionId);
  201. return;
  202. }
  203. // 复用同一个 Resolution 结构交给同一个投递实现,确保站内信 / push / 日志
  204. // 与配置路径逐字一致——绝不在此另起第二套发送代码。
  205. var resolution = new S8RecipientResolution
  206. {
  207. // 新来源标识,与既有的 RECIPIENT_MODEL / LEGACY_LAYER / NONE 均不冲突,
  208. // 日志里一眼能看出这批人不是配置解析来的。
  209. Source = "EXPLICIT",
  210. UserIds = distinct,
  211. // PerType 让既有日志与 ConfiguredButEmpty 语义仍然成立且可读。
  212. PerType = new Dictionary<string, int> { ["EXPLICIT"] = distinct.Count },
  213. };
  214. await DispatchToUsersAsync(input, resolution);
  215. }
  216. /// <summary>
  217. /// 新收件人模型的投递。复用与 LEGACY 完全相同的 <see cref="BuildNotification"/> 与
  218. /// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致,
  219. /// 否则同一条异常会因为配置来源不同而呈现出不同的消息。
  220. /// </summary>
  221. private async Task DispatchToUsersAsync(DispatchByLayerInput input, S8RecipientResolution resolution)
  222. {
  223. if (resolution.UserIds.Count == 0)
  224. {
  225. // 0 收件人必须可观测:配了却发不出去,与"没配置"是两回事。
  226. // 绝不静默返回让调用方以为发送成功 —— 那正是本模块反复出现的失败形态。
  227. _logger.LogWarning(
  228. "S8RecipientDispatch: resolved 0 recipients (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
  229. input.TenantId, input.EventCode, input.ExceptionId,
  230. string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
  231. return;
  232. }
  233. _logger.LogInformation(
  234. "S8RecipientDispatch: source={Source} recipients={Count} (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})",
  235. resolution.Source, resolution.UserIds.Count, input.TenantId, input.EventCode, input.ExceptionId,
  236. string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}")));
  237. // ① 站内信:这是本部署里**唯一会为用户留下一条可见消息**的通道。
  238. //
  239. // 已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms —— 没有站内信。
  240. // 旧的分层派发因此从来只推 SignalR(用户不在线就等于没发过),
  241. // 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。
  242. // 新模型把两者合并:所有事件都先落站内信,再推实时通道。
  243. // 少了这一步,本次重构会静默丢掉认领 / 转派原本有的站内信(本地实测已发生)。
  244. try
  245. {
  246. await _noticeService.PublishToUsersAsync(
  247. input.Title,
  248. BuildNoticeContent(input),
  249. resolution.UserIds.ToArray(),
  250. 0,
  251. "S8异常监控");
  252. }
  253. catch (Exception ex)
  254. {
  255. _logger.LogWarning(ex, "S8RecipientDispatch: notice publish throw (event={Event})", input.EventCode);
  256. }
  257. // ② 实时通道:在线用户即时可见;不在线也不影响①已经留下的站内信。
  258. try
  259. {
  260. await _pushAdapter.PushAsync(
  261. tenantId: input.TenantId,
  262. factoryId: S8ConfigScope.GlobalFactoryId,
  263. exceptionId: input.ExceptionId,
  264. userIds: resolution.UserIds,
  265. notification: BuildNotification(input),
  266. channels: DefaultChannels);
  267. }
  268. catch (Exception ex)
  269. {
  270. _logger.LogWarning(ex, "S8RecipientDispatch: push throw (event={Event})", input.EventCode);
  271. }
  272. }
  273. /// <summary>站内信正文:内容 + 一个能点回异常详情的链接。</summary>
  274. private static string BuildNoticeContent(DispatchByLayerInput input)
  275. {
  276. var jump = string.IsNullOrWhiteSpace(input.JumpUrl) ? null : $"/#{input.JumpUrl}";
  277. return $"<p>{input.ExceptionNo} {input.Content}</p>"
  278. + (jump == null ? string.Empty : $"<p><a href=\"{jump}\">查看异常详情</a></p>");
  279. }
  280. /// <summary>
  281. /// 新模型的投递渠道。只列<b>真实可用</b>的两个 —— 站内信与 SignalR,
  282. /// 与旧分层表里实际在用的 <c>notify_channel='log,SignalR'</c> 同源。
  283. /// 不为了 UI 看起来完整而虚构短信 / 邮件之类当前根本没有实现的渠道。
  284. /// </summary>
  285. private static readonly List<string> DefaultChannels = new() { "log", "SignalR" };
  286. private static FlowNotification BuildNotification(DispatchByLayerInput input)
  287. {
  288. // 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
  289. // InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
  290. var ctx = new Dictionary<string, string?>
  291. {
  292. ["exceptionId"] = input.ExceptionId?.ToString(),
  293. ["exceptionNo"] = input.ExceptionNo,
  294. ["sceneCode"] = input.SceneCode,
  295. ["severity"] = input.Severity,
  296. ["status"] = input.Status,
  297. ["sourceRuleCode"] = input.SourceRuleCode,
  298. ["jumpUrl"] = input.JumpUrl,
  299. };
  300. if (input.Recovered) ctx["recovered"] = "true";
  301. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
  302. if (input.RepeatCount30d.HasValue)
  303. ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
  304. if (input.CumulativeLossHours30d.HasValue)
  305. ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  306. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
  307. ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
  308. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
  309. ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
  310. if (!string.IsNullOrWhiteSpace(input.ImpactReason))
  311. ctx["impactReason"] = input.ImpactReason;
  312. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
  313. if (input.OverdueClosed == true)
  314. ctx["overdueClosed"] = "true";
  315. if (input.ClosedAt.HasValue)
  316. ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  317. if (input.SlaDeadlineRef.HasValue)
  318. ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  319. if (input.OverdueCloseHours.HasValue)
  320. ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  321. return new FlowNotification
  322. {
  323. Type = FlowNotificationTypeEnum.NewTask,
  324. BizType = "S8_EXCEPTION",
  325. InstanceId = input.ExceptionId ?? 0,
  326. Title = input.Title ?? string.Empty,
  327. Content = input.Content ?? string.Empty,
  328. Context = ctx,
  329. };
  330. }
  331. public static List<string> ParseChannels(string? csv)
  332. {
  333. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  334. return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  335. }
  336. }