using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.ApprovalFlow.Service; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// /// S8 通知分层路由解析器(S8-NOTIFY-LAYER-RESOLVE-1) /// /// 职责链: /// (tenantId, factoryId, sceneCode, severity) /// → 命中 ado_s8_notification_layer 行(多行 OK) /// → 解析 target_role_ids(dual-format) /// → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔) /// → S8RoleResolver → userIds /// → S8NotificationPushAdapter.PushAsync /// → 由 PushAdapter 落 AdoS8NotificationLog(**每个「已注册且被选中」的渠道**一条) /// /// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog; /// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。 /// /// S8-LEGACY-NOTIFY-DECOMMISSION-1:**LEGACY 分层已从 Runtime 退役。** /// 本类不再读取 ado_s8_notification_layer,收件人唯一来源是 /// ado_s8_notification_recipient(Rule + Event + RecipientType)。 /// 类名保留 "Layer" 只为不牵动全部调用方;它现在是通知派发入口,与分层无关。 /// 旧表 / Entity / CRUD / Seed 类仍存在但已无 Runtime 消费者,物理清理见 P1-C3。 /// public class S8NotificationLayerResolver : ITransient { private readonly IS8NotificationRecipientResolver _recipientResolver; private readonly Admin.NET.Core.Service.SysNoticeService _noticeService; private readonly S8NotificationPushAdapter _pushAdapter; private readonly ILogger _logger; public S8NotificationLayerResolver( IS8NotificationRecipientResolver recipientResolver, Admin.NET.Core.Service.SysNoticeService noticeService, S8NotificationPushAdapter pushAdapter, ILogger logger) { _recipientResolver = recipientResolver; _noticeService = noticeService; _pushAdapter = pushAdapter; _logger = logger; } public class DispatchByLayerInput { public long TenantId { get; set; } public long? ExceptionId { get; set; } public string? ExceptionNo { get; set; } public string SceneCode { get; set; } = string.Empty; public string Severity { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public string? Status { get; set; } public string? SourceRuleCode { get; set; } /// /// S8-NOTIFY-RECIPIENT-1:本次派发对应的事件码(S8NotifyEventCode)。 /// 为空表示调用方尚未接入新收件人模型,本次直接走 LEGACY 分层。 /// public string? EventCode { get; set; } /// /// 触发本次派发的异常单本体。新模型要靠它解析 ASSIGNEE / REVIEWER / HANDLER_POOL。 /// 为 null 时同样退回 LEGACY 分层。 /// public Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8Exception? ExceptionRef { get; set; } public string? JumpUrl { get; set; } /// /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加 /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。 /// public bool Recovered { get; set; } // ============================================================ // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计字段(可选;CREATED 路径透传,RECOVERED 路径置空)。 // 由 S8WatchSchedulerService.TryDispatchLayerNotificationAsync 调 S8ImpactMetricsService 计算后传入。 // ============================================================ public int? RepeatCount30d { get; set; } public decimal? CumulativeLossHours30d { get; set; } public string? SuggestedAttentionLevel { get; set; } public string? SuggestedAttentionLabel { get; set; } public string? ImpactReason { get; set; } // ============================================================ // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警字段(可选;仅 CloseAsync 命中 closedAt > slaDeadline 时传入)。 // 语义与 TimeoutFlag 运行时口径分离:TimeoutFlag 仅看未关闭超时;OverdueClosed 是已关闭后的闭环及时性提醒。 // ============================================================ public bool? OverdueClosed { get; set; } public DateTime? ClosedAt { get; set; } public DateTime? SlaDeadlineRef { get; set; } public decimal? OverdueCloseHours { get; set; } } /// /// 调用方:watch/scheduler/task 在拿到 sceneCode + severity 后调用本方法(本轮不接入主链路)。 /// public async Task DispatchByLayerAsync(DispatchByLayerInput input) { if (input == null) { _logger.LogWarning("S8LayerDispatch: input null"); return; } if (string.IsNullOrWhiteSpace(input.SceneCode) || string.IsNullOrWhiteSpace(input.Severity)) { _logger.LogInformation("S8LayerDispatch skip: empty sceneCode or severity (exceptionId={ExceptionId})", input.ExceptionId); return; } // ── S8-LEGACY-NOTIFY-DECOMMISSION-1:LEGACY 分层已从 Runtime 彻底退役 ── // // **本方法不再读取 ado_s8_notification_layer。** 通知收件人的唯一来源是 // Rule-centric 新模型(ado_s8_notification_recipient): // Event → RecipientType → 责任池 / 当前处理人 / 当前复核人 → SysUser // // 为什么整段删掉而不是留个开关:旧链路按 (scene, severity, level) 选人, // 与新模型的 (rule, event, responsibility) 是两套互不相干的维度。留着它, // 「新模型没配」就会静默走另一套语义,而管理员在规则弹窗里根本看不到那批人 —— // 这正是本模块反复出现的失败形态。退役后语义变成:**没配就是没配**, // 缺口会以 0 收件人的形式暴露出来,而不是被旧配置悄悄兜住。 // // 刻意不动既有的 fail-loud 语义:新模型查到配置但解析出 0 人时, // 仍由 DispatchToUsersAsync 落 Warning 后返回,不做任何回落。 // // 已知缺口(本批明确不修,见 P0-C2 回执):人工主动提报的异常 // SourceRuleCode 为 NULL,新模型会以 rule_code='*' 为键查找,而 '*' 目前 // 没有任何业务写入路径 —— 这类异常的后续事件通知暂时无收件人来源。 // 该缺口由「主动提报通知契约」独立梳理,**不以恢复 LEGACY 的方式兜底**。 if (string.IsNullOrWhiteSpace(input.EventCode) || input.ExceptionRef == null) { _logger.LogInformation( "S8NotifyDispatch skip: 缺少 eventCode 或 exception 引用,无法解析新模型收件人 (exceptionId={ExceptionId} event={Event})", input.ExceptionId, input.EventCode); return; } var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef); if (resolution.Source == "NONE") { // 没有配置就是没有配置 —— 不再有第二套语义接管。 _logger.LogInformation( "S8NotifyDispatch skip: 该 (租户,规则,事件) 未配置收件人,不发送 (tenant={Tenant} rule={Rule} event={Event} exceptionId={ExceptionId})", input.TenantId, input.ExceptionRef.SourceRuleCode, input.EventCode, input.ExceptionId); return; } await DispatchToUsersAsync(input, resolution); } /// /// S8-NOTIFY-EXPLICIT-1:把通知投递给调用方显式给定的账号,**跳过收件人配置解析**。 /// /// 为什么这条路径不需要配置行: 解决的是 /// 「这类事件该通知谁」——答案要从 ado_s8_notification_recipient 的 /// (租户, 规则, 事件) 配置推导,所以没配置就必须停在 Source == "NONE" 的门上。 /// 本方法解决的是另一个问题:收件人在调用现场已经是确定的具体账号 /// (由业务动作本身决定,例如建单时手选的对象),不存在"该通知谁"的推导空间。 /// 对这种输入再去查一遍配置表只有一种结果:配置缺失时把一条本该送达的通知丢掉—— /// 即"人已经指名道姓选好了,却因为管理员没配规则而没收到"。 /// 因此这里刻意不经过 _recipientResolver.ResolveAsync, /// 也就不会被 Source == "NONE" 提前拦截。 /// /// 为什么不在通知层重做身份校验:账号是否存在、是否启用、是否属于本租户, /// 是调用方的前置(IS8UserScopeValidator.EnsureActiveUserAsync)—— /// 收件人本来就是它校验通过后才交到这里的。通知层若再造一套判断,就等于在系统里 /// 存在第二个身份 Authority:两处规则迟早不一致,而不一致的那一刻没有任何报错, /// 只表现为"某些人莫名其妙收不到通知"。所以本方法只负责投递, /// 不做账号有效性 / 租户归属判断,也不因此放宽任何边界—— /// 边界仍在调用方,且调用方必须先校验再调本方法。 /// /// 本批只提供能力,不接任何具体事件; /// EXCEPTION_TRANSFERRED / VERIFICATION_SUBMITTED 等接线登记为 Deferred。 /// /// 通知载荷,与配置路径同一个入参类型,保证两条路径构造出的消息完全一致。 /// 显式收件账号(SysUser.Id)。允许重复,内部去重。 public async Task DispatchToExplicitUsersAsync(DispatchByLayerInput input, IReadOnlyCollection userIds) { // 前置与 DispatchByLayerAsync 保持一致:入参本身缺失属于调用方 bug,落 Warning。 if (input == null) { _logger.LogWarning("S8ExplicitDispatch: input null"); return; } // ExceptionRef 同样沿用配置路径的前置。本方法虽不靠它解析收件人, // 但它是"这条通知挂在哪张单据上"的唯一凭据,缺了就无法在排障时定位, // 也说明调用现场根本没拿到单据本体——那种状态下不该发通知。 if (input.ExceptionRef == null) { _logger.LogInformation( "S8ExplicitDispatch skip: 缺少 exception 引用,不发送 (exceptionId={ExceptionId} event={Event})", input.ExceptionId, input.EventCode); return; } // 刻意**不**沿用 sceneCode / severity 的前置:那一条挡的是配置路径的 // **路由键**(要靠它们选人)。本路径收件人已给定,两者只是载荷字段, // 缺失不该让一条指名道姓的通知静默消失。 // 去重;同时剔除非正数(0 / 负数不是合法 SysUser.Id,多半是调用方没取到值)。 var distinct = (userIds ?? Array.Empty()) .Where(id => id > 0) .Distinct() .ToList(); if (distinct.Count == 0) { // 明确 no-op 而不是静默返回:调用方以为发出去了、实际一个人都没发, // 是本模块反复出现的失败形态,必须留下可检索的记录。 _logger.LogInformation( "S8ExplicitDispatch skip: 显式收件人为空(原始 {Raw} 个,去重且过滤非法 Id 后为 0),不发送 (tenant={Tenant} event={Event} exceptionId={ExceptionId})", userIds?.Count ?? 0, input.TenantId, input.EventCode, input.ExceptionId); return; } // 复用同一个 Resolution 结构交给同一个投递实现,确保站内信 / push / 日志 // 与配置路径逐字一致——绝不在此另起第二套发送代码。 var resolution = new S8RecipientResolution { // 新来源标识,与既有的 RECIPIENT_MODEL / LEGACY_LAYER / NONE 均不冲突, // 日志里一眼能看出这批人不是配置解析来的。 Source = "EXPLICIT", UserIds = distinct, // PerType 让既有日志与 ConfiguredButEmpty 语义仍然成立且可读。 PerType = new Dictionary { ["EXPLICIT"] = distinct.Count }, }; await DispatchToUsersAsync(input, resolution); } /// /// 新收件人模型的投递。复用与 LEGACY 完全相同的 与 /// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致, /// 否则同一条异常会因为配置来源不同而呈现出不同的消息。 /// private async Task DispatchToUsersAsync(DispatchByLayerInput input, S8RecipientResolution resolution) { if (resolution.UserIds.Count == 0) { // 0 收件人必须可观测:配了却发不出去,与"没配置"是两回事。 // 绝不静默返回让调用方以为发送成功 —— 那正是本模块反复出现的失败形态。 _logger.LogWarning( "S8RecipientDispatch: resolved 0 recipients (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})", input.TenantId, input.EventCode, input.ExceptionId, string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}"))); return; } _logger.LogInformation( "S8RecipientDispatch: source={Source} recipients={Count} (tenant={Tenant} event={Event} exceptionId={ExceptionId} perType={PerType})", resolution.Source, resolution.UserIds.Count, input.TenantId, input.EventCode, input.ExceptionId, string.Join(',', resolution.PerType.Select(kv => $"{kv.Key}={kv.Value}"))); // ① 站内信:这是本部署里**唯一会为用户留下一条可见消息**的通道。 // // 已注册的 pusher 只有 DingTalk / WorkWeixin / SignalR / Sms —— 没有站内信。 // 旧的分层派发因此从来只推 SignalR(用户不在线就等于没发过), // 而认领 / 转派的站内信是靠一段写死收件人的代码单独发的。 // 新模型把两者合并:所有事件都先落站内信,再推实时通道。 // 少了这一步,本次重构会静默丢掉认领 / 转派原本有的站内信(本地实测已发生)。 try { await _noticeService.PublishToUsersAsync( input.Title, BuildNoticeContent(input), resolution.UserIds.ToArray(), 0, "S8异常监控"); } catch (Exception ex) { _logger.LogWarning(ex, "S8RecipientDispatch: notice publish throw (event={Event})", input.EventCode); } // ② 实时通道:在线用户即时可见;不在线也不影响①已经留下的站内信。 try { await _pushAdapter.PushAsync( tenantId: input.TenantId, factoryId: S8ConfigScope.GlobalFactoryId, exceptionId: input.ExceptionId, userIds: resolution.UserIds, notification: BuildNotification(input), channels: DefaultChannels); } catch (Exception ex) { _logger.LogWarning(ex, "S8RecipientDispatch: push throw (event={Event})", input.EventCode); } } /// 站内信正文:内容 + 一个能点回异常详情的链接。 private static string BuildNoticeContent(DispatchByLayerInput input) { var jump = string.IsNullOrWhiteSpace(input.JumpUrl) ? null : $"/#{input.JumpUrl}"; return $"{input.ExceptionNo} {input.Content}" + (jump == null ? string.Empty : $"查看异常详情"); } /// /// 新模型的投递渠道。只列真实可用的两个 —— 站内信与 SignalR, /// 与旧分层表里实际在用的 notify_channel='log,SignalR' 同源。 /// 不为了 UI 看起来完整而虚构短信 / 邮件之类当前根本没有实现的渠道。 /// private static readonly List DefaultChannels = new() { "log", "SignalR" }; private static FlowNotification BuildNotification(DispatchByLayerInput input) { // 注意:FlowNotificationTypeEnum 不含 S8_EXCEPTION 值;BizType 字段承载 "S8_EXCEPTION" 语义。 // InstanceId 仅作为载荷字段(PushAdapter 不写 ApprovalFlowNotifyLog,不会脏写该列)。 var ctx = new Dictionary { ["exceptionId"] = input.ExceptionId?.ToString(), ["exceptionNo"] = input.ExceptionNo, ["sceneCode"] = input.SceneCode, ["severity"] = input.Severity, ["status"] = input.Status, ["sourceRuleCode"] = input.SourceRuleCode, ["jumpUrl"] = input.JumpUrl, }; if (input.Recovered) ctx["recovered"] = "true"; // S8-DEMO-IMPACT-SORT-NOTICE-1:影响统计 5 字段,仅 CREATED 路径携带,RECOVERED 路径不传入。 if (input.RepeatCount30d.HasValue) ctx["repeatCount30d"] = input.RepeatCount30d.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); if (input.CumulativeLossHours30d.HasValue) ctx["cumulativeLossHours30d"] = input.CumulativeLossHours30d.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture); if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLevel)) ctx["suggestedAttentionLevel"] = input.SuggestedAttentionLevel; if (!string.IsNullOrWhiteSpace(input.SuggestedAttentionLabel)) ctx["suggestedAttentionLabel"] = input.SuggestedAttentionLabel; if (!string.IsNullOrWhiteSpace(input.ImpactReason)) ctx["impactReason"] = input.ImpactReason; // S8-R03-OVERDUE-CLOSE-NOTICE-1:关闭超时独立预警 4 字段,仅 CloseAsync 命中 closedAt > slaDeadline 时携带。 if (input.OverdueClosed == true) ctx["overdueClosed"] = "true"; if (input.ClosedAt.HasValue) ctx["closedAt"] = input.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); if (input.SlaDeadlineRef.HasValue) ctx["slaDeadline"] = input.SlaDeadlineRef.Value.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); if (input.OverdueCloseHours.HasValue) ctx["overdueCloseHours"] = input.OverdueCloseHours.Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture); return new FlowNotification { Type = FlowNotificationTypeEnum.NewTask, BizType = "S8_EXCEPTION", InstanceId = input.ExceptionId ?? 0, Title = input.Title ?? string.Empty, Content = input.Content ?? string.Empty, Context = ctx, }; } public static List ParseChannels(string? csv) { if (string.IsNullOrWhiteSpace(csv)) return new List(); return csv.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } }
{input.ExceptionNo} {input.Content}
查看异常详情