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; /// /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:通知分层「平台默认 / 工厂覆盖」的**单一** merge 契约。 /// /// 覆盖粒度 = (scene_code, 归一 severity, 归一 level_code),即逐层覆盖。 /// /// 修正前(S8-STEP6C-CFG-NOTIFY-CLOSURE-1 及更早)覆盖粒度是整个 (scene, severity) 键: /// resolver 先按 (tenant,factory,scene,severity) 精确查,**仅当该键 0 行**才回落 (0,0)。 /// 后果是工厂只要为某一层建一行(例如只配 L2_MANAGER),平台默认在该 scene+severity 下的 /// **其余层级(L1/L3)会整体消失**——工厂本意是「补一层」,实际却是「换掉整组」, /// 且该副作用在页面上不可见。本类把粒度收敛到 level,使「补一层」就只影响那一层。 /// /// ⚠️ resolver 与配置页 ListAsync 必须都走 /// 两处一旦各自实现,就会出现「页面显示一种生效配置、运行时按另一种派发」的静默背离 /// (已实测过一次:resolver 用 (0,0) 基线派发通知,而配置页只查精确租户 → 页面空表、通知照发)。 /// public static class S8NotificationLayerMerge { /// 层级归一:去空白 + 大写。历史脏数据存在 level_code = '' 的行,归一后自成一组,不与合法层级混淆。 public static string NormalizeLevel(string? levelCode) => (levelCode ?? string.Empty).Trim().ToUpperInvariant(); /// merge 键:(scene_code, 归一 severity, 归一 level_code)。 public static (string Scene, string Severity, string Level) KeyOf(AdoS8NotificationLayer x) => (x.SceneCode ?? string.Empty, S8SeverityCode.Normalize(x.Severity), NormalizeLevel(x.LevelCode)); /// /// 从「平台默认行 ∪ 本工厂行」的候选集算出生效集合:逐 (scene, severity, level) 取工厂优先, /// 该层无工厂行则回落平台默认。 /// /// 排序为稳定确定序:scene → severity → level → id。 /// 末位 Id 是必要的——DB 侧无唯一索引(唯一性仅由 service 层 AnyAsync 前置检查保证), /// 同键重复行若依赖 DB 返回序,派发顺序会在不同查询计划下漂移。 /// public static List Effective(IEnumerable candidates) => S8ConfigScope.EffectiveRows(candidates, x => x.TenantId, x => x.FactoryId, KeyOf) .OrderBy(x => x.SceneCode, StringComparer.Ordinal) .ThenBy(x => S8SeverityCode.Normalize(x.Severity), StringComparer.Ordinal) .ThenBy(x => NormalizeLevel(x.LevelCode), StringComparer.Ordinal) .ThenBy(x => x.Id) .ToList(); }