S8NotificationLayerResolver.cs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. using Admin.NET.Plugin.ApprovalFlow.Service;
  4. using Microsoft.Extensions.Logging;
  5. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  6. /// <summary>
  7. /// S8 通知分层路由解析器(S8-NOTIFY-LAYER-RESOLVE-1)
  8. ///
  9. /// 职责链:
  10. /// (tenantId, factoryId, sceneCode, severity)
  11. /// → 命中 ado_s8_notification_layer 行(多行 OK)
  12. /// → 解析 target_role_ids(dual-format)
  13. /// → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔)
  14. /// → S8RoleResolver → userIds
  15. /// → S8NotificationPushAdapter.PushAsync
  16. /// → 由 PushAdapter 落 AdoS8NotificationLog(每渠道一条)
  17. ///
  18. /// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
  19. /// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
  20. ///
  21. /// tenant/factory baseline fallback:先精确匹配 (tenantId, factoryId);
  22. /// 若 0 行,再尝试 (tenant=0, factory=0) baseline;仍 0 行 LogInformation 后 return。
  23. /// </summary>
  24. public class S8NotificationLayerResolver : ITransient
  25. {
  26. private readonly SqlSugarRepository<AdoS8NotificationLayer> _layerRep;
  27. private readonly S8RoleResolver _roleResolver;
  28. private readonly S8NotificationPushAdapter _pushAdapter;
  29. private readonly ILogger<S8NotificationLayerResolver> _logger;
  30. public S8NotificationLayerResolver(
  31. SqlSugarRepository<AdoS8NotificationLayer> layerRep,
  32. S8RoleResolver roleResolver,
  33. S8NotificationPushAdapter pushAdapter,
  34. ILogger<S8NotificationLayerResolver> logger)
  35. {
  36. _layerRep = layerRep;
  37. _roleResolver = roleResolver;
  38. _pushAdapter = pushAdapter;
  39. _logger = logger;
  40. }
  41. public class DispatchByLayerInput
  42. {
  43. public long TenantId { get; set; }
  44. public long FactoryId { get; set; }
  45. public long? ExceptionId { get; set; }
  46. public string? ExceptionNo { get; set; }
  47. public string SceneCode { get; set; } = string.Empty;
  48. public string Severity { get; set; } = string.Empty;
  49. public string Title { get; set; } = string.Empty;
  50. public string Content { get; set; } = string.Empty;
  51. public string? Status { get; set; }
  52. public string? SourceRuleCode { get; set; }
  53. public string? JumpUrl { get; set; }
  54. /// <summary>
  55. /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加
  56. /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。
  57. /// </summary>
  58. public bool Recovered { get; set; }
  59. // ============================================================
  60. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。
  61. // 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。
  62. // ============================================================
  63. public int? RepeatCount30d { get; set; }
  64. public decimal? CumulativeLossHours30d { get; set; }
  65. public string? SuggestedAttentionLevel { get; set; }
  66. public string? SuggestedAttentionLabel { get; set; }
  67. public string? ImpactReason { get; set; }
  68. // ============================================================
  69. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。
  70. // 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。
  71. // ============================================================
  72. public bool? OverdueClosed { get; set; }
  73. public DateTime? ClosedAt { get; set; }
  74. public DateTime? SlaDeadlineRef { get; set; }
  75. public decimal? OverdueCloseHours { get; set; }
  76. }
  77. /// <summary>
  78. /// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。
  79. /// </summary>
  80. public async Task DispatchByLayerAsync(DispatchByLayerInput input)
  81. {
  82. if (input == null)
  83. {
  84. _logger.LogWarning("S8LayerDispatch: input null");
  85. return;
  86. }
  87. if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity))
  88. {
  89. _logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId);
  90. return;
  91. }
  92. List<AdoS8NotificationLayer> layers;
  93. try
  94. {
  95. layers = await _layerRep.AsQueryable()
  96. .Where(x => x.TenantId == input.TenantId
  97. && x.FactoryId == input.FactoryId
  98. && x.SceneCode == input.SceneCode
  99. && x.Severity == S8SeverityCode.Normalize(input.Severity))
  100. .ToListAsync();
  101. // baseline fallback:未命中精确租户/工厂 → 尝试 tenant=0/factory=0 全局基线
  102. // (task 第 4 条允许;schema 中 tenant_id/factory_id 为 NOT NULL bigint,0 视为基线占位)
  103. if (layers.Count == 0)
  104. {
  105. layers = await _layerRep.AsQueryable()
  106. .Where(x => x.TenantId == 0
  107. && x.FactoryId == 0
  108. && x.SceneCode == input.SceneCode
  109. && x.Severity == S8SeverityCode.Normalize(input.Severity))
  110. .ToListAsync();
  111. if (layers.Count > 0)
  112. _logger.LogInformation("S8LayerDispatch: matched {N} baseline (tenant=0/factory=0) layer rows", layers.Count);
  113. }
  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. {
  122. _logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
  123. input.TenantId, input.FactoryId, input.SceneCode, input.Severity);
  124. return;
  125. }
  126. var notification = BuildNotification(input);
  127. foreach (var layer in layers)
  128. {
  129. List<long> userIds;
  130. try
  131. {
  132. var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds);
  133. if (tokens.Count == 0)
  134. {
  135. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id);
  136. continue;
  137. }
  138. userIds = await _roleResolver.ResolveUserIdsAsync(tokens);
  139. }
  140. catch (Exception ex)
  141. {
  142. _logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id);
  143. continue;
  144. }
  145. if (userIds.Count == 0)
  146. {
  147. _logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'",
  148. layer.Id, layer.TargetRoleIds);
  149. continue;
  150. }
  151. var channels = ParseChannels(layer.NotifyChannel);
  152. try
  153. {
  154. await _pushAdapter.PushAsync(
  155. tenantId: input.TenantId,
  156. factoryId: input.FactoryId,
  157. exceptionId: input.ExceptionId,
  158. userIds: userIds,
  159. notification: notification,
  160. channels: channels);
  161. }
  162. catch (Exception ex)
  163. {
  164. // PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
  165. _logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
  166. }
  167. }
  168. }
  169. private static FlowNotification BuildNotification(DispatchByLayerInput input)
  170. {
  171. // 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。
  172. // InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。
  173. var ctx = new Dictionary<string, string?>
  174. {
  175. ["exceptionId"] = input.ExceptionId?.ToString(),
  176. ["exceptionNo"] = input.ExceptionNo,
  177. ["sceneCode"] = input.SceneCode,
  178. ["severity"] = input.Severity,
  179. ["status"] = input.Status,
  180. ["sourceRuleCode"] = input.SourceRuleCode,
  181. ["jumpUrl"] = input.JumpUrl,
  182. };
  183. if (input.Recovered) ctx["recovered"] = "true";
  184. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。
  185. if (input.RepeatCount30d.HasValue)
  186. ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
  187. if (input.CumulativeLossHours30d.HasValue)
  188. ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  189. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel))
  190. ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel;
  191. if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel))
  192. ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel;
  193. if (!string.IsNullOrWhiteSpace(input.ImpactReason))
  194. ctx["impactReason"] = input.ImpactReason;
  195. // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。
  196. if (input.OverdueClosed == true)
  197. ctx["overdueClosed"] = "true";
  198. if (input.ClosedAt.HasValue)
  199. ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  200. if (input.SlaDeadlineRef.HasValue)
  201. ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
  202. if (input.OverdueCloseHours.HasValue)
  203. ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
  204. return new FlowNotification
  205. {
  206. Type = FlowNotificationTypeEnum.NewTask,
  207. BizType = "S8_EXCEPTION",
  208. InstanceId = input.ExceptionId ?? 0,
  209. Title = input.Title ?? string.Empty,
  210. Content = input.Content ?? string.Empty,
  211. Context = ctx,
  212. };
  213. }
  214. public static List<string> ParseChannels(string? csv)
  215. {
  216. if (string.IsNullOrWhiteSpace(csv)) return new List<string>();
  217. return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  218. }
  219. }