using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8NotificationLayerService : ITransient { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _roleRep; public S8NotificationLayerService( SqlSugarRepository rep, SqlSugarRepository roleRep) { _rep = rep; _roleRep = roleRep; } /// /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:生效配置视图,**严格镜像 S8NotificationLayerResolver**。 /// /// 原实现只查精确 (tenantId, factoryId),而 14 条基线种在 (0,0),导致任何真实租户打开页面都是空表; /// 与此同时 resolver 在运行时**确实**用这 14 条基线派发通知 —— 即「配置页看不见、通知照发」。 /// /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:merge 不再在本方法内实现,改为与 resolver 共用 /// (覆盖粒度 = scene + severity + level)。 /// 两处共用同一函数是硬性要求:各写一份相似 GroupBy 必然分叉成 /// 「页面显示一种生效配置、运行时按另一种派发」。 /// public async Task> ListAsync(long tenantId, long factoryId) { var all = await _rep.AsQueryable() .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId) || (x.TenantId == tenantId && x.FactoryId == factoryId)) .ToListAsync(); var globalKeys = all .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)) .Select(S8NotificationLayerMerge.KeyOf) .ToHashSet(); var effective = S8NotificationLayerMerge.Effective(all); foreach (var row in effective) row.HasGlobalDefault = globalKeys.Contains(S8NotificationLayerMerge.KeyOf(row)); return effective; } /// /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级词表。原先服务端只校验非空,任意字符串可入库 /// (实测库中存在 level_code = '' 的历史行);前端固定三档,此处与之对齐为单一事实源。 /// private static readonly string[] AllowedLevelCodes = { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" }; /// /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:通知渠道词表 —— 只允许当前真正可消费的渠道。 /// /// log逻辑空操作 / 诊断渠道,不产生任何投递、也不写通知日志。 /// S8NotificationPushAdapter 的投递循环遍历的是**已注册 pusher 集合**并按 wanted 过滤 /// (foreach (var pusher in registered) { if (!wanted.Contains(pusher.Channel)) continue; ... }), /// 而 ado_s8_notification_log 的两个写入点都在该循环体内; /// 全仓 5 个 INotifyPusher 实现的 Channel 为 SignalR / Email / Sms / DingTalk / WorkWeixin, /// **没有 Channel == "log" 的实现**,因此 log 永远进不了循环,只在收尾处打一行 /// 「has no registered pusher, skip」的 ILogger 记录。 /// ⚠️ 前一版本注释把 log 写成「等价于只落库」是**错的**:它一行都不落。 /// /// SignalR:进程内 hub 推送给已认证的在线连接,无第三方 / 公网 egress; /// 这是当前唯一会**真实投递**并因此**写一条投递审计日志**的渠道 /// (无在线连接时 pusher 早退返回 Ok(0),仍写一条 targetCount=0 的日志)。 /// /// notify_channel = "log,SignalR" 每次派发恰好落 1 条日志(SignalR 那条), /// log 贡献 0 条。 /// /// 刻意不含 Email / Sms / DingTalk / WorkWeixin:这四个渠道当前在 ApprovalFlow.json 全为 false、 /// ApprovalFlowNotifyConfig 表 0 行,属「就位不启用」;暴露为可选项会造成「看似可用」, /// 且 push adapter 在 cfg == null 时会跳过开关检查放行已注册 pusher,届时即成真实外发。 /// 要启用须先完成渠道凭据与开关的独立评审。 /// private static readonly string[] AllowedChannels = { "log", "SignalR" }; /// /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:场景词表校验。 /// /// 新建一律要求 canonical 单模块场景 S1–S7(判定复用 , /// 它是仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。 /// 原先服务端对 scene_code **零词表校验**,任意字符串都能入库; /// 而前端下拉来自租户自建的 ado_s8_scene_config 且开着 allow-create,等于对外开放自由文本。 /// /// 编辑时若 scene 与库中现值**逐字相同**则放行:库里存在 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等 /// 历史行,若无条件强制 canonical,这些行会变成「连改渠道都保存不了」的砖块。 /// 判据刻意用「与现值逐字相等」而非「在 legacy 常量表内」——S8_DEMO_DEFAULT 不在任何常量表里。 /// private static void ValidateSceneCode(string sceneCode, string? unchangedFrom) { if (S8ModuleCode.IsValid(sceneCode)) return; if (unchangedFrom != null && string.Equals(sceneCode, unchangedFrom, StringComparison.Ordinal)) return; throw new S8BizException( "不支持的场景编码:" + sceneCode + ";当前仅支持 " + string.Join(" / ", S8ModuleCode.All)); } /// /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:严重度词表校验,**必须在 Normalize 之前调用**。 /// /// S8SeverityCode.Normalize 的兜底分支是 _ => Follow:任何垃圾串都会被静默降级成 FOLLOW /// 而不报错——用户把 SERIOUS 拼错就会得到一条「保存成功但严重度悄悄变了」的配置。 /// 因此这里先做严格判定再归一。 /// /// 也刻意**不复用** S8SeverityCode.IsValid:那是宽松版, /// FOLLOW / SERIOUS / LOW / MEDIUM / HIGH / CRITICAL 六值全放行(供 legacy 查询参数兼容用), /// 拿来当写入门禁会直接放过 legacy 值。 /// /// 编辑时同样允许「与库中现值逐字相同」的 legacy 值通过,理由同 。 /// private static void ValidateSeverity(string severity, string? unchangedFrom) { if (string.Equals(severity, S8SeverityCode.Follow, StringComparison.Ordinal) || string.Equals(severity, S8SeverityCode.Serious, StringComparison.Ordinal)) return; if (unchangedFrom != null && string.Equals(severity, unchangedFrom, StringComparison.Ordinal)) return; throw new S8BizException( "不支持的严重度:" + severity + ";当前仅支持 " + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious); } private static void ValidateVocabulary(AdoS8NotificationLayer body) { if (!AllowedLevelCodes.Contains(body.LevelCode, StringComparer.OrdinalIgnoreCase)) throw new S8BizException( "不支持的层级编码:" + body.LevelCode + ";当前仅支持 " + string.Join(" / ", AllowedLevelCodes)); var channels = (body.NotifyChannel ?? string.Empty) .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); if (channels.Count == 0) throw new S8BizException("通知渠道至少选一个"); var bad = channels .Where(c => !AllowedChannels.Contains(c, StringComparer.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (bad.Count > 0) throw new S8BizException( "不支持的通知渠道:" + string.Join(" / ", bad) + ";当前仅支持 " + string.Join(" / ", AllowedChannels)); // 归一大小写并去重,避免同一渠道以不同大小写重复写入。 body.NotifyChannel = string.Join(",", channels .Select(c => AllowedChannels.First(a => string.Equals(a, c, StringComparison.OrdinalIgnoreCase))) .Distinct(StringComparer.Ordinal)); } /// /// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:通知分层的**目标角色候选**。 /// /// 原实现按**默认租户的物理 Id** 筛选 /// (Id BETWEEN 1329908000101 AND 1329908000107 外加 1300000000101)。 /// 这些 Id 全部属于默认租户 1300000000001;而 AidopTenantInitService /// 是**按租户各复制一份**这 7 个角色的——同 Code、不同 Id。 /// 于是在任何非默认租户下,仓储的租户过滤把这些 Id 全部滤掉,候选列表恒为空, /// 配置页无法查看或编辑目标角色。(UAT 实测:自有 7 个 canonical 角色,候选却返回 0。) /// /// 修正为**按 canonical Role Code 白名单在可信租户作用域内查**: /// Code 是逻辑身份、跨租户稳定,Id 是租户本地物理身份。 /// 这里**不显式传 tenantId**——租户边界由 SqlSugarRepository<SysRole> 的 /// 全局租户过滤按认证身份施加,与 S8 其余读路径同一机制;为这一个方法另开旁路 /// 反而会绕过可信作用域。 /// /// 返回形状保持 { value, label } 不变(value = Code): /// ado_s8_notification_layer.target_role_ids 存的就是 Code, /// 改成物理 Id 会当场废掉既有配置与 S8RoleResolver 的解析口径。 /// 顺带的好处是**出参里根本没有 Id**,也就不存在把默认租户物理 Id 泄漏给其它租户的可能。 /// /// 缺失行为(有意):canonical 白名单里某个 Code 在当前租户不存在时, /// **只是不出现这一项**——不回落默认租户、不自动建角色、不报错。 /// public async Task> GetRoleOptionsAsync() { var codes = S8CanonicalRole.BusinessRoleCodes; var roles = await _roleRep.AsQueryable() .Where(x => codes.Contains(x.Code)) .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(); return ShapeRoleOptions(roles.Select(x => (x.Id, x.Code, x.Name))) // 附加 id 仅供追溯(证明选中的是本租户物理行);value 仍是 Code,形状向后兼容。 .Select(x => (object)new { value = x.Code, label = x.Name, id = x.Id }) .ToList(); } /// /// role-options 的**纯整形逻辑**(去重 + 排序),与 DB 解耦以便直接单测。 /// /// 去重:同租户同 Code 重复目前实测为 0(无 DB 唯一索引,属已登记技术债)。 /// 取 Id 最小者是为了保证**确定性**,不是为了把重复数据藏起来—— /// 重复本身仍应由数据治理批次处理。 /// /// 排序:由 canonical OrderNo 决定,**不由物理 Id 决定**; /// 否则同一份配置在不同租户会呈现不同顺序。 /// public static List<(long Id, string Code, string Name)> ShapeRoleOptions( IEnumerable<(long Id, string Code, string Name)> rows) => rows.GroupBy(x => x.Code, StringComparer.Ordinal) .Select(g => g.OrderBy(x => x.Id).First()) .OrderBy(x => S8CanonicalRole.OrderOf(x.Code)) .ThenBy(x => x.Code, StringComparer.Ordinal) .ToList(); // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。 public async Task CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope) { if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode)) throw new S8BizException("场景、严重度、层级必填"); if (string.IsNullOrWhiteSpace(body.TargetRoleIds)) throw new S8BizException("目标角色必填"); if (string.IsNullOrWhiteSpace(body.NotifyChannel)) throw new S8BizException("通知渠道至少选一个"); // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:新建一律要求 canonical 词表,无 legacy 豁免(unchangedFrom = null)。 // 严重度必须在 Normalize **之前**校验,否则兜底分支会把非法值静默降级成 FOLLOW,门禁永远命中不了。 ValidateSceneCode(body.SceneCode, unchangedFrom: null); ValidateSeverity(body.Severity, unchangedFrom: null); // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。 body.Severity = S8SeverityCode.Normalize(body.Severity); // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。 ValidateVocabulary(body); body.TenantId = scope.TenantId; body.FactoryId = scope.FactoryId; var exists = await _rep.AsQueryable() .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode); if (exists) throw new S8BizException("同场景+严重度+层级已存在"); body.Id = 0; body.CreatedAt = DateTime.Now; return await _rep.InsertReturnEntityAsync(body); } // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。 public async Task UpdateAsync(long id, AdoS8NotificationLayer body, S8TrustedScope scope) { var e = await LoadScopedAsync(id, scope); if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode)) throw new S8BizException("场景、严重度、层级必填"); if (string.IsNullOrWhiteSpace(body.TargetRoleIds)) throw new S8BizException("目标角色必填"); if (string.IsNullOrWhiteSpace(body.NotifyChannel)) throw new S8BizException("通知渠道至少选一个"); // S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:编辑允许保留「与库中现值逐字相同」的 legacy 场景 / 严重度, // 否则 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等历史行会连改渠道都保存不了;改成新值则必须 canonical。 ValidateSceneCode(body.SceneCode, unchangedFrom: e.SceneCode); ValidateSeverity(body.Severity, unchangedFrom: e.Severity); // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:归一为 FOLLOW/SERIOUS 后再校验/写入。 body.Severity = S8SeverityCode.Normalize(body.Severity); // S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级与渠道走白名单,杜绝保存消费方无法识别的配置。 ValidateVocabulary(body); var exists = await _rep.AsQueryable() .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId && x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode); if (exists) throw new S8BizException("同场景+严重度+层级已存在"); body.Id = id; body.TenantId = e.TenantId; body.FactoryId = e.FactoryId; body.CreatedAt = e.CreatedAt; body.UpdatedAt = DateTime.Now; await _rep.UpdateAsync(body); return body; } // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。 public async Task DeleteAsync(long id, S8TrustedScope scope) { var e = await LoadScopedAsync(id, scope); await _rep.DeleteByIdAsync(e.Id); } private async Task LoadScopedAsync(long id, S8TrustedScope scope) => await _rep.AsQueryable() .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId) .FirstAsync() ?? throw new S8NotFoundException(); }