using Admin.NET.Plugin.AiDOP.Entity.S8; 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,不向上抛。 /// /// tenant/factory baseline fallback:先精确匹配 (tenantId, factoryId); /// 若 0 行,再尝试 (tenant=0, factory=0) baseline;仍 0 行 LogInformation 后 return。 /// public class S8NotificationLayerResolver : ITransient { private readonly SqlSugarRepository _layerRep; private readonly S8RoleResolver _roleResolver; private readonly S8NotificationPushAdapter _pushAdapter; private readonly ILogger _logger; public S8NotificationLayerResolver( SqlSugarRepository layerRep, S8RoleResolver roleResolver, S8NotificationPushAdapter pushAdapter, ILogger logger) { _layerRep = layerRep; _roleResolver = roleResolver; _pushAdapter = pushAdapter; _logger = logger; } public class DispatchByLayerInput { public long TenantId { get; set; } public long FactoryId { 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; } public string? JumpUrl { get; set; } /// /// S8-NOTIFY-WIRE-RECOVERED-1:true 表示恢复事件,BuildNotification 会在 Context 中追加 /// "recovered"="true"。默认 false,保持 CREATED 路径载荷向后兼容。 /// public bool Recovered { 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; } List layers; try { layers = await _layerRep.AsQueryable() .Where(x => x.TenantId == input.TenantId && x.FactoryId == input.FactoryId && x.SceneCode == input.SceneCode && x.Severity == S8SeverityCode.Normalize(input.Severity)) .ToListAsync(); // baseline fallback:未命中精确租户/工厂 → 尝试 tenant=0/factory=0 全局基线 // (task 第 4 条允许;schema 中 tenant_id/factory_id 为 NOT NULL bigint,0 视为基线占位) if (layers.Count == 0) { layers = await _layerRep.AsQueryable() .Where(x => x.TenantId == 0 && x.FactoryId == 0 && x.SceneCode == input.SceneCode && x.Severity == S8SeverityCode.Normalize(input.Severity)) .ToListAsync(); if (layers.Count > 0) _logger.LogInformation("S8LayerDispatch: matched {N} baseline (tenant=0/factory=0) layer rows", layers.Count); } } catch (Exception ex) { _logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity); return; } if (layers.Count == 0) { _logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})", input.TenantId, input.FactoryId, input.SceneCode, input.Severity); return; } var notification = BuildNotification(input); foreach (var layer in layers) { List userIds; try { var tokens = S8RoleResolver.SplitTokens(layer.TargetRoleIds); if (tokens.Count == 0) { _logger.LogWarning("S8LayerDispatch: layer id={LayerId} target_role_ids empty, skip row", layer.Id); continue; } userIds = await _roleResolver.ResolveUserIdsAsync(tokens); } catch (Exception ex) { _logger.LogWarning(ex, "S8LayerDispatch: resolve roles failed for layer id={LayerId}", layer.Id); continue; } if (userIds.Count == 0) { _logger.LogWarning("S8LayerDispatch: layer id={LayerId} resolved 0 users from target_role_ids='{Roles}'", layer.Id, layer.TargetRoleIds); continue; } var channels = ParseChannels(layer.NotifyChannel); try { await _pushAdapter.PushAsync( tenantId: input.TenantId, factoryId: input.FactoryId, exceptionId: input.ExceptionId, userIds: userIds, notification: notification, channels: channels); } catch (Exception ex) { // PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。 _logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id); } } } 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"; 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(); } }