Преглед изворни кода

fix(s8): close notification configuration runtime contract | Web 2.4.341 / server 1.0.423

YY968XX пре 18 часа
родитељ
комит
4fe695948b

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.340",
+	"version": "2.4.341",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 48 - 11
Web/src/views/aidop/s8/config/S8NotificationLayerPage.vue

@@ -26,10 +26,24 @@
 			<el-table-column prop="updatedAt" label="更新时间" width="170">
 				<template #default="{ row }">{{ row.updatedAt ?? row.createdAt }}</template>
 			</el-table-column>
-			<el-table-column label="操作" width="130" fixed="right">
+			<!-- S8-STEP6C-CFG-NOTIFY-CLOSURE-1:列表现在同时包含平台默认与本工厂行,必须让用户看清来源。 -->
+			<el-table-column prop="scope" label="配置来源" width="120">
 				<template #default="{ row }">
-					<el-button size="small" @click="openEdit(row)">编辑</el-button>
-					<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
+					<el-tag :type="row.scope === 'GLOBAL' ? 'info' : 'success'" size="small">
+						{{ row.scope === 'GLOBAL' ? '平台默认' : '本工厂' }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column label="操作" width="200" fixed="right">
+				<template #default="{ row }">
+					<!-- 平台默认行租户侧只读:按 S8ConfigScope 契约,不给最终只会 404 的编辑/删除动作。 -->
+					<template v-if="row.scope === 'GLOBAL'">
+						<span class="scope-readonly">平台默认,不可修改</span>
+					</template>
+					<template v-else>
+						<el-button size="small" @click="openEdit(row)">编辑</el-button>
+						<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
+					</template>
 				</template>
 			</el-table-column>
 		</el-table>
@@ -38,11 +52,14 @@
 		<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑通知分层' : '新增通知分层'" width="560px" destroy-on-close>
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
 				<el-form-item label="场景编码" prop="sceneCode">
+					<!--
+						S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:去掉 allow-create。
+						服务端新建已强制 canonical 场景(S1–S7),保留自由输入只会让用户提交后吃 400。
+					-->
 					<el-select
 						v-model="form.sceneCode"
 						filterable
-						allow-create
-						placeholder="选择或输入场景编码"
+						placeholder="选择场景编码"
 						style="width: 100%"
 					>
 						<el-option v-for="opt in sceneOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
@@ -70,9 +87,19 @@
 				</el-form-item>
 				<el-form-item>
 					<span style="font-size: 12px; color: var(--el-text-color-secondary)">
-						渠道启停受全局通知配置控制(ApprovalFlowNotifyConfig)
+						当前仅开放 log(仅记录诊断日志,不投递)与 SignalR(站内推送);邮件 / 短信 / 钉钉 / 企微尚未启用,暂不可选。
 					</span>
 				</el-form-item>
+				<!--
+					S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度已收敛为「场景 + 严重度 + 层级」逐层覆盖。
+					原文案「该场景+严重度的全部分层将不再生效」描述的是修正前的整键接管语义,现已不成立,
+					继续保留会把用户吓退(误以为补一层要连带重配所有层级)。
+				-->
+				<el-form-item v-if="!isEdit && form.sceneCode && form.severity && form.levelCode">
+					<el-alert type="info" :closable="false" show-icon>
+						本工厂同场景、严重度、层级配置将覆盖平台默认配置。其它层级的平台默认不受影响。
+					</el-alert>
+				</el-form-item>
 			</el-form>
 			<template #footer>
 				<el-button @click="dialogVisible = false">取消</el-button>
@@ -102,12 +129,16 @@ const levelOptions = [
 	{ value: 'L3_DIRECTOR', label: 'L3 总监' },
 ];
 
+// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:渠道选项收敛为「当前真正可消费」的两个,与后端 AllowedChannels 单一事实源。
+//   log     —— 无注册 pusher,S8NotificationPushAdapter 会 skip,等价于只落 ado_s8_notification_log;
+//   SignalR —— 进程内 hub 推给已认证的在线连接,无第三方 / 公网 egress。
+// 原选项含 Email / Sms / DingTalk / WorkWeixin:这四个渠道当前 ApprovalFlow.json 全为 false、
+// ApprovalFlowNotifyConfig 表 0 行,属「就位不启用」,列出来即「看似可用」;后端现已 400 拒绝。
+// 另:库中既有行的 notify_channel 均为 'log,SignalR',原先选项不含 log,编辑既有行会静默丢掉 log。
 const channelOptions = [
-	{ value: 'SignalR', label: 'SignalR' },
-	{ value: 'Email', label: 'Email' },
-	{ value: 'Sms', label: 'Sms' },
-	{ value: 'DingTalk', label: 'DingTalk' },
-	{ value: 'WorkWeixin', label: 'WorkWeixin' },
+	// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:log 无注册 pusher,既不投递也不写通知日志(原「仅落库」措辞有误)。
+	{ value: 'log', label: 'log(仅诊断日志,不投递)' },
+	{ value: 'SignalR', label: 'SignalR(站内推送)' },
 ];
 
 // ── state ───────────────────────────────────────────────────────────────────
@@ -271,4 +302,10 @@ async function handleDelete(row: any) {
 	display: flex;
 	justify-content: flex-end;
 }
+
+/* S8-STEP6C-CFG-NOTIFY-CLOSURE-1:平台默认行的只读占位文案。 */
+.scope-readonly {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
 </style>

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.422</AssemblyVersion>
-    <FileVersion>1.0.422</FileVersion>
-    <Version>1.0.422</Version>
+    <AssemblyVersion>1.0.423</AssemblyVersion>
+    <FileVersion>1.0.423</FileVersion>
+    <Version>1.0.423</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 213 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLayerLiveDataTests.cs

@@ -0,0 +1,213 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:通知分层生效集合的**只读**真库校验。
+///
+/// <para><b>门禁</b>:仅当 <c>AIDOP_IT=1</c> 时执行(无 DB 的普通 CI 直接跳过,不失败)。
+/// 沿用仓内既有 Pattern B(见 S5/MaterialWarehouse/L0LiveLoaderIntegrationTests):
+/// 连接串从 Admin.NET.Application/Configuration/Database.json 现取,**凭据不写入测试源码**。</para>
+///
+/// <para><b>严格只读</b>:本文件只执行 SELECT。
+/// CLAUDE.md 第八节规定业务/配置数据写入一律走 API,禁止直连写库绕过应用层的租户隔离、
+/// 字段校验与审计——因此这里**不 INSERT / UPDATE / DELETE 任何一行**,
+/// 「新建工厂覆盖行后生效集合如何变化」改由内存候选集拼装来验证
+/// (拼装用的平台默认行是真库实数据,工厂行是内存构造的假想行)。</para>
+///
+/// <para><b>为什么不在这里做端到端派发测试</b>:
+/// <c>SqlSugarRepository&lt;T&gt;</c> 的无参构造会触碰 <c>Furion.App.HttpContext</c>,
+/// 而 <c>Furion.App</c> 的静态构造在无宿主的 xunit 进程中直接抛 TypeInitializationException
+/// (实测:App.GetAssemblies → App.Settings → NRE)。故 S8NotificationLayerService /
+/// S8NotificationLayerResolver / S8NotificationPushAdapter **无法在纯测试进程内实例化**,
+/// 派发链路与通知日志写入的验证必须在真实运行的服务里做(页面 / 接口),不在本文件冒充。</para>
+/// </summary>
+[Trait("Category", "Integration")]
+public class S8NotificationLayerLiveDataTests
+{
+    /// <summary>UAT 可信作用域(只读查询用)。</summary>
+    private const long UatTenantId = 838257186181189L;
+
+    private const long UatFactoryId = 838257186320453L;
+
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private static ISqlSugarClient? BuildAidopdev()
+    {
+        const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
+        if (!File.Exists(path)) return null;
+        var cs = File.ReadLines(path)
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
+                        && l.Contains("Database=aidopdev"))
+            .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
+            .FirstOrDefault(v => !string.IsNullOrEmpty(v));
+        if (string.IsNullOrEmpty(cs)) return null;
+        return new SqlSugarScope(new ConnectionConfig
+        {
+            ConfigId = "aidopdev-s8-notify-it",
+            DbType = DbType.MySql,
+            ConnectionString = cs,
+            IsAutoCloseConnection = true,
+        });
+    }
+
+    /// <summary>只读取回 UAT 视角的候选集:平台默认 (0,0) ∪ 本工厂行。与生产 ListAsync 的取数条件一致。</summary>
+    private static async Task<List<AdoS8NotificationLayer>> CandidatesAsync(ISqlSugarClient db) =>
+        await db.Queryable<AdoS8NotificationLayer>()
+            .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
+                     || (x.TenantId == UatTenantId && x.FactoryId == UatFactoryId))
+            .ToListAsync();
+
+    /// <summary>真库里的平台默认基线:14 条,S1–S7 × {FOLLOW, SERIOUS},均为 L1_OPERATOR。</summary>
+    [Fact]
+    public async Task PlatformDefaultBaseline_IsIntactAndCanonical()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var globals = (await CandidatesAsync(db!))
+            .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
+            .ToList();
+
+        Assert.Equal(14, globals.Count);
+        Assert.Equal(
+            new[] { "S1", "S2", "S3", "S4", "S5", "S6", "S7" },
+            globals.Select(x => x.SceneCode).Distinct().OrderBy(x => x, StringComparer.Ordinal));
+        Assert.All(globals, g =>
+        {
+            Assert.Contains(S8SeverityCode.Normalize(g.Severity), new[] { "FOLLOW", "SERIOUS" });
+            Assert.Equal("L1_OPERATOR", g.LevelCode);
+            // 渠道仍限定在无外发能力的两个渠道内(log 不投递,SignalR 为进程内推送)。
+            Assert.All(
+                (g.NotifyChannel ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+                c => Assert.Contains(c, new[] { "log", "SignalR" }));
+        });
+    }
+
+    /// <summary>
+    /// 零残留守卫:本批开发过程中曾短暂存在一版会写库的测试(在构造 SqlSugarRepository 时即抛
+    /// Furion TypeInitializationException,未走到任何 INSERT)。此用例长期断言
+    /// 通知分层表内不存在任何合成测试作用域的行,确保没有测试残渣被当成业务配置。
+    /// </summary>
+    [Fact]
+    public async Task NoSyntheticTestRowsLeakedIntoConfigTable()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        // 95xxxxxxxxxxxxx 是仓内测试作用域惯用号段,正式业务不使用。
+        var residue = await db!.Queryable<AdoS8NotificationLayer>()
+            .Where(x => x.TenantId >= 950000000000000L && x.TenantId < 960000000000000L)
+            .CountAsync();
+        Assert.Equal(0, residue);
+
+        var syntheticScenes = await db.Queryable<AdoS8NotificationLayer>()
+            .Where(x => x.SceneCode.StartsWith("S8_IT_"))
+            .CountAsync();
+        Assert.Equal(0, syntheticScenes);
+    }
+
+    /// <summary>UAT 当前无工厂覆盖行 → 生效集合应等于平台默认基线本身。</summary>
+    [Fact]
+    public async Task UatEffectiveSet_EqualsPlatformDefault_WhenNoFactoryOverrideExists()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var candidates = await CandidatesAsync(db!);
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        var factoryRows = candidates.Count(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId));
+        if (factoryRows > 0) return; // 有人正在配工厂覆盖,本断言前提不成立,跳过而非误报
+
+        Assert.Equal(14, effective.Count);
+        Assert.All(effective, r => Assert.Equal("GLOBAL", r.Scope));
+    }
+
+    /// <summary>
+    /// N-1 回归(真库平台默认 + 内存假想工厂行,全程不写库):
+    /// 工厂只覆盖 S1/FOLLOW 的 L2_MANAGER 时,平台默认的 S1/FOLLOW/L1_OPERATOR 必须仍然生效,
+    /// 且其余 13 条平台默认一条都不能少。修正前这里会丢掉 S1/FOLLOW 的平台默认行。
+    /// </summary>
+    [Fact]
+    public async Task HypotheticalFactoryOverrideOnL2_DoesNotSuppressPlatformDefaultOnL1()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var candidates = await CandidatesAsync(db!);
+        if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return;
+
+        var hypothetical = new AdoS8NotificationLayer
+        {
+            Id = -1, // 内存假想行,绝不入库
+            TenantId = UatTenantId,
+            FactoryId = UatFactoryId,
+            SceneCode = "S1",
+            Severity = "FOLLOW",
+            LevelCode = "L2_MANAGER",
+            TargetRoleIds = "ROLE_ORDER_PLANNER",
+            NotifyChannel = "log,SignalR"
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical));
+
+        // 平台默认 14 条一条不少,外加工厂新增的那一层
+        Assert.Equal(15, effective.Count);
+
+        var s1Follow = effective
+            .Where(r => r.SceneCode == "S1" && S8SeverityCode.Normalize(r.Severity) == "FOLLOW")
+            .ToList();
+        Assert.Equal(2, s1Follow.Count);
+        Assert.Contains(s1Follow, r => r.LevelCode == "L1_OPERATOR" && r.Scope == "GLOBAL");
+        Assert.Contains(s1Follow, r => r.LevelCode == "L2_MANAGER" && r.Scope == "FACTORY");
+
+        // 其它 scene/severity 完全不受影响:14 条平台默认里,除 S1+FOLLOW 那条外的 13 条全部原样保留
+        Assert.Equal(13, effective.Count(r => r.Scope == "GLOBAL"
+                                              && !(r.SceneCode == "S1"
+                                                   && S8SeverityCode.Normalize(r.Severity) == "FOLLOW")));
+    }
+
+    /// <summary>同层级的假想工厂行则应当替换掉该层平台默认,总数不变。</summary>
+    [Fact]
+    public async Task HypotheticalFactoryOverrideOnSameLevel_ReplacesPlatformDefault()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var candidates = await CandidatesAsync(db!);
+        if (candidates.Any(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))) return;
+
+        var hypothetical = new AdoS8NotificationLayer
+        {
+            Id = -2,
+            TenantId = UatTenantId,
+            FactoryId = UatFactoryId,
+            SceneCode = "S1",
+            Severity = "FOLLOW",
+            LevelCode = "L1_OPERATOR",
+            TargetRoleIds = "ROLE_ORDER_PLANNER",
+            NotifyChannel = "SignalR"
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates.Append(hypothetical));
+
+        Assert.Equal(14, effective.Count);
+        var row = effective.Single(r => r.SceneCode == "S1"
+                                        && S8SeverityCode.Normalize(r.Severity) == "FOLLOW");
+        Assert.Equal("FACTORY", row.Scope);
+        Assert.Equal(-2, row.Id);
+    }
+}

+ 217 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationLayerMergeTests.cs

@@ -0,0 +1,217 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1 · N-1 回归。
+///
+/// 覆盖 <see cref="S8NotificationLayerMerge"/> 的逐层覆盖语义。这是纯函数测试,不触 DB、不触 pusher。
+/// runtime resolver 与配置页 ListAsync 都调用同一个 Effective(),因此本文件同时守住两处。
+///
+/// 被守住的缺陷(修正前真实存在):工厂只为某一层建行,平台默认在该 scene+severity 下的
+/// **其余层级会整体消失** —— 工厂本意「补一层」,实际「换掉整组」,且页面上不可见。
+/// </summary>
+public class S8NotificationLayerMergeTests
+{
+    private const long GlobalTenant = 0L;
+    private const long GlobalFactory = 0L;
+    private const long FactoryTenant = 838257186181189L;
+    private const long FactoryFactory = 838257186320453L;
+
+    private static AdoS8NotificationLayer Row(
+        long id, long tenantId, long factoryId, string scene, string severity, string level) =>
+        new()
+        {
+            Id = id,
+            TenantId = tenantId,
+            FactoryId = factoryId,
+            SceneCode = scene,
+            Severity = severity,
+            LevelCode = level,
+            TargetRoleIds = "ROLE_X",
+            NotifyChannel = "log,SignalR"
+        };
+
+    private static AdoS8NotificationLayer Global(long id, string scene, string sev, string level) =>
+        Row(id, GlobalTenant, GlobalFactory, scene, sev, level);
+
+    private static AdoS8NotificationLayer Factory(long id, string scene, string sev, string level) =>
+        Row(id, FactoryTenant, FactoryFactory, scene, sev, level);
+
+    /// <summary>
+    /// N-1 的核心回归:工厂只覆盖 L2,平台默认的 L1 / L3 必须**继续生效**。
+    /// 修正前此处会只剩 1 行(工厂的 L2),L1/L3 静默消失。
+    /// </summary>
+    [Fact]
+    public void FactoryOverrideOnOneLevel_KeepsPlatformDefaultsOnOtherLevels()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Global(2, "S1", "FOLLOW", "L2_MANAGER"),
+            Global(3, "S1", "FOLLOW", "L3_DIRECTOR"),
+            Factory(100, "S1", "FOLLOW", "L2_MANAGER"),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Equal(3, effective.Count);
+        Assert.Equal(new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" }, effective.Select(x => x.LevelCode));
+        // L1 / L3 仍来自平台默认,L2 被工厂接管
+        Assert.Equal(GlobalTenant, effective[0].TenantId);
+        Assert.Equal(FactoryTenant, effective[1].TenantId);
+        Assert.Equal(100, effective[1].Id);
+        Assert.Equal(GlobalTenant, effective[2].TenantId);
+    }
+
+    /// <summary>同层级有工厂行时,该层级的平台默认必须被替换掉(不能两行都发)。</summary>
+    [Fact]
+    public void FactoryRow_ReplacesPlatformDefault_OnSameLevel()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Single(effective);
+        Assert.Equal(100, effective[0].Id);
+    }
+
+    /// <summary>覆盖不跨 scene / severity:S1 的工厂行不得影响 S2,FOLLOW 的不得影响 SERIOUS。</summary>
+    [Fact]
+    public void Override_DoesNotLeakAcrossSceneOrSeverity()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Global(2, "S1", "SERIOUS", "L1_OPERATOR"),
+            Global(3, "S2", "FOLLOW", "L1_OPERATOR"),
+            Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Equal(3, effective.Count);
+        Assert.Equal(100, effective.Single(x => x.SceneCode == "S1" && x.Severity == "FOLLOW").Id);
+        Assert.Equal(2, effective.Single(x => x.SceneCode == "S1" && x.Severity == "SERIOUS").Id);
+        Assert.Equal(3, effective.Single(x => x.SceneCode == "S2").Id);
+    }
+
+    /// <summary>
+    /// severity 参与分组前必须归一:平台默认写 FOLLOW、工厂行写 legacy 的 LOW,
+    /// 二者是同一档,必须归到同一组由工厂接管,而不是各发一条。
+    /// </summary>
+    [Fact]
+    public void SeverityIsNormalizedBeforeGrouping()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Factory(100, "S1", "LOW", "L1_OPERATOR"),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Single(effective);
+        Assert.Equal(100, effective[0].Id);
+    }
+
+    /// <summary>level_code 归一:大小写 / 空白差异不得让工厂行与平台默认分到两组。</summary>
+    [Fact]
+    public void LevelIsNormalizedBeforeGrouping()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Factory(100, "S1", "FOLLOW", " l1_operator "),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Single(effective);
+        Assert.Equal(100, effective[0].Id);
+    }
+
+    /// <summary>
+    /// 历史脏数据:level_code = '' 的行自成一组,既不吞掉合法层级、也不被合法层级吞掉。
+    /// (库中实测存在这样的行。)
+    /// </summary>
+    [Fact]
+    public void EmptyLevelCode_FormsItsOwnGroup()
+    {
+        var candidates = new[]
+        {
+            Global(1, "S1", "SERIOUS", "L1_OPERATOR"),
+            Factory(100, "S1", "SERIOUS", ""),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Equal(2, effective.Count);
+        Assert.Contains(effective, x => x.Id == 1);
+        Assert.Contains(effective, x => x.Id == 100);
+    }
+
+    /// <summary>
+    /// 无唯一索引,同键可能重复行:必须全部保留(丢弃会让 runtime 与页面不一致),
+    /// 且顺序按 Id 稳定,不依赖 DB 返回序。
+    /// </summary>
+    [Fact]
+    public void DuplicateRowsOnSameKey_AreAllKept_InStableIdOrder()
+    {
+        var candidates = new[]
+        {
+            Factory(300, "S1", "FOLLOW", "L1_OPERATOR"),
+            Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+        };
+
+        var effective = S8NotificationLayerMerge.Effective(candidates);
+
+        Assert.Equal(new[] { 100L, 300L }, effective.Select(x => x.Id));
+    }
+
+    /// <summary>输入顺序不影响输出:稳定确定序是 scene → severity → level → id。</summary>
+    [Fact]
+    public void OrderingIsDeterministic_RegardlessOfInputOrder()
+    {
+        var rows = new[]
+        {
+            Global(3, "S2", "FOLLOW", "L1_OPERATOR"),
+            Global(2, "S1", "SERIOUS", "L1_OPERATOR"),
+            Global(1, "S1", "FOLLOW", "L2_MANAGER"),
+            Global(4, "S1", "FOLLOW", "L1_OPERATOR"),
+        };
+
+        var forward = S8NotificationLayerMerge.Effective(rows).Select(x => x.Id).ToList();
+        var reversed = S8NotificationLayerMerge.Effective(rows.Reverse().ToList()).Select(x => x.Id).ToList();
+
+        Assert.Equal(new[] { 4L, 1L, 2L, 3L }, forward);
+        Assert.Equal(forward, reversed);
+    }
+
+    /// <summary>只有平台默认时全部生效;只有工厂行时同理。</summary>
+    [Fact]
+    public void SingleSidedCandidates_PassThrough()
+    {
+        var globalsOnly = S8NotificationLayerMerge.Effective(new[]
+        {
+            Global(1, "S1", "FOLLOW", "L1_OPERATOR"),
+            Global(2, "S1", "FOLLOW", "L2_MANAGER"),
+        });
+        Assert.Equal(2, globalsOnly.Count);
+
+        var factoryOnly = S8NotificationLayerMerge.Effective(new[]
+        {
+            Factory(100, "S1", "FOLLOW", "L1_OPERATOR"),
+        });
+        Assert.Single(factoryOnly);
+
+        Assert.Empty(S8NotificationLayerMerge.Effective(Array.Empty<AdoS8NotificationLayer>()));
+    }
+}

+ 21 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLayer.cs

@@ -33,4 +33,25 @@ public class AdoS8NotificationLayer
 
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     public DateTime? UpdatedAt { get; set; }
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:作用域标识(不落库)。
+    /// GLOBAL = 平台默认 (0,0),租户侧只读;FACTORY = 当前工厂自有行。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string Scope => Admin.NET.Plugin.AiDOP.Infrastructure.S8ConfigScope.Resolve(TenantId, FactoryId);
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:本行所属 (scene_code, severity, level_code) 键是否存在平台默认行
+    /// (不落库,由 ListAsync 赋值)。
+    ///
+    /// 含义与 exception_type / role_permission 等「逐行覆盖」对象**一致**:
+    /// 「本工厂这一行覆盖了同键的平台默认行」。
+    ///
+    /// ⚠️ 修正记录:S8-STEP6C-CFG-NOTIFY-CLOSURE-1 版本下覆盖粒度曾是整个 (scene, severity) 键
+    /// (resolver 精确命中该键即完全不读 (0,0)),本标记当时的含义是「本工厂一旦自建行就整键接管」。
+    /// 该语义已被 S8NotificationLayerMerge 收敛为逐 level 覆盖,本注释同批修正。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public bool HasGlobalDefault { get; set; }
 }

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8ConfigScope.cs

@@ -36,4 +36,28 @@ public static class S8ConfigScope
     /// <summary>把归属列翻译成作用域标识,供前端按业务语言展示(不暴露 tenantId / factoryId 本身)。</summary>
     public static string Resolve(long tenantId, long factoryId) =>
         IsGlobal(tenantId, factoryId) ? Global : Factory;
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:「平台默认 + 工厂覆盖」的**唯一** merge 实现。
+    ///
+    /// 语义:按 <paramref name="keyOf"/> 分组;组内存在工厂行 → 只取工厂行;否则回落该组的平台默认行。
+    /// 覆盖粒度**完全等于** key 的粒度——调用方通过选择 key 决定「一行覆盖一行」还是「整组接管」。
+    ///
+    /// ⚠️ 本方法是 runtime resolver 与配置页 List 的共同实现,**不得各自复制一份相似 GroupBy**:
+    /// 两处一旦分叉,就会出现「页面显示一种生效配置、运行时用另一种」的静默背离
+    /// (S8-STEP6C 已实测过一次:resolver 用 (0,0) 基线派发,而配置页只查精确租户 → 页面空表、通知照发)。
+    ///
+    /// 返回组内**全部**入选行(不是单行),因为 DB 侧无唯一索引(唯一性仅由 service 层 AnyAsync 前置检查保证),
+    /// 同 key 可能存在重复行;丢弃它们会让 runtime 与页面再次不一致。调用方需自行决定最终排序。
+    /// </summary>
+    public static IEnumerable<T> EffectiveRows<T, TKey>(
+        IEnumerable<T> all,
+        Func<T, long> tenantOf,
+        Func<T, long> factoryOf,
+        Func<T, TKey> keyOf) =>
+        all.GroupBy(keyOf).SelectMany(g =>
+        {
+            var own = g.Where(x => !IsGlobal(tenantOf(x), factoryOf(x))).ToList();
+            return own.Count > 0 ? own : g.Where(x => IsGlobal(tenantOf(x), factoryOf(x)));
+        });
 }

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerMerge.cs

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

+ 29 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -1,4 +1,5 @@
 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;
@@ -15,13 +16,16 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 ///     → 解析 notify_channel(IgnoreCase, ',' / ';' 分隔)
 ///     → S8RoleResolver → userIds
 ///     → S8NotificationPushAdapter.PushAsync
-///     → 由 PushAdapter 落 AdoS8NotificationLog(每渠道一条)
+///     → 由 PushAdapter 落 AdoS8NotificationLog(**个「已注册且被选中」的渠道**一条)
 ///
 /// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
 /// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
 ///
-/// tenant/factory baseline fallback:先精确匹配 (tenantId, factoryId);
-/// 若 0 行,再尝试 (tenant=0, factory=0) baseline;仍 0 行 LogInformation 后 return。
+/// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:平台默认回落粒度 = **(scene, severity, level) 逐层**。
+/// 一次 OR 查询取回「平台默认 (0,0) ∪ 本工厂」的同 scene+severity 行,再交
+/// <see cref="S8NotificationLayerMerge.Effective"/> 逐 level 取工厂优先。
+/// 修正前是整键 fallback(精确租户查到任意一行即完全不读 (0,0)),会导致工厂补一层就丢掉平台其余层级。
+/// merge 与配置页 ListAsync 共用同一实现,禁止各自复制。
 /// </summary>
 public class S8NotificationLayerResolver : ITransient
 {
@@ -97,29 +101,25 @@ public class S8NotificationLayerResolver : ITransient
 			return;
 		}
 
+		// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
+		//
+		// 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
+		// 那意味着工厂只要为某一层建一行,平台默认在该 scene+severity 下的其余层级会整体消失——
+		// 工厂本意是「补一层」,实际是「换掉整组」,且该副作用在配置页上不可见。
+		//
+		// 现在一次 OR 查询同时取回平台默认与本工厂行,再由 S8NotificationLayerMerge 逐 level 取舍。
+		// merge 与配置页 ListAsync 共用同一函数,杜绝「页面显示一种、运行时派发另一种」。
 		List<AdoS8NotificationLayer> layers;
+		var severity = S8SeverityCode.Normalize(input.Severity);
 		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))
+			var candidates = await _layerRep.AsQueryable()
+				.Where(x => x.SceneCode == input.SceneCode && x.Severity == severity)
+				.Where(x => (x.TenantId == input.TenantId && x.FactoryId == input.FactoryId)
+					|| (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId))
 				.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);
-			}
+			layers = S8NotificationLayerMerge.Effective(candidates);
 		}
 		catch (Exception ex)
 		{
@@ -127,6 +127,14 @@ public class S8NotificationLayerResolver : ITransient
 			return;
 		}
 
+		if (layers.Count > 0)
+			_logger.LogInformation(
+				"S8LayerDispatch: {Total} effective layer rows across {Levels} level(s) for scene={Scene} sev={Sev}; {G} from platform default (tenant=0/factory=0)",
+				layers.Count,
+				layers.Select(x => S8NotificationLayerMerge.NormalizeLevel(x.LevelCode)).Distinct().Count(),
+				input.SceneCode, severity,
+				layers.Count(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)));
+
 		if (layers.Count == 0)
 		{
 			_logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",

+ 142 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs

@@ -18,11 +18,138 @@ public class S8NotificationLayerService : ITransient
         _roleRep = roleRep;
     }
 
-    public async Task<List<AdoS8NotificationLayer>> ListAsync(long tenantId, long factoryId) =>
-        await _rep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:生效配置视图,**严格镜像 S8NotificationLayerResolver**。
+    ///
+    /// 原实现只查精确 (tenantId, factoryId),而 14 条基线种在 (0,0),导致任何真实租户打开页面都是空表;
+    /// 与此同时 resolver 在运行时**确实**用这 14 条基线派发通知 —— 即「配置页看不见、通知照发」。
+    ///
+    /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:merge 不再在本方法内实现,改为与 resolver 共用
+    /// <see cref="S8NotificationLayerMerge.Effective"/>(覆盖粒度 = scene + severity + level)。
+    /// 两处共用同一函数是硬性要求:各写一份相似 GroupBy 必然分叉成
+    /// 「页面显示一种生效配置、运行时按另一种派发」。
+    /// </summary>
+    public async Task<List<AdoS8NotificationLayer>> 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;
+    }
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:层级词表。原先服务端只校验非空,任意字符串可入库
+    /// (实测库中存在 level_code = '' 的历史行);前端固定三档,此处与之对齐为单一事实源。
+    /// </summary>
+    private static readonly string[] AllowedLevelCodes = { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" };
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-CLOSURE-1:通知渠道词表 —— 只允许当前真正可消费的渠道。
+    ///
+    /// <para><b>log</b>:<b>逻辑空操作 / 诊断渠道,不产生任何投递、也不写通知日志。</b>
+    /// S8NotificationPushAdapter 的投递循环遍历的是**已注册 pusher 集合**并按 wanted 过滤
+    /// (<c>foreach (var pusher in registered) { if (!wanted.Contains(pusher.Channel)) continue; ... }</c>),
+    /// 而 <c>ado_s8_notification_log</c> 的两个写入点都在该循环体内;
+    /// 全仓 5 个 INotifyPusher 实现的 Channel 为 SignalR / Email / Sms / DingTalk / WorkWeixin,
+    /// **没有 Channel == "log" 的实现**,因此 log 永远进不了循环,只在收尾处打一行
+    /// 「has no registered pusher, skip」的 ILogger 记录。
+    /// ⚠️ 前一版本注释把 log 写成「等价于只落库」是**错的**:它一行都不落。</para>
+    ///
+    /// <para><b>SignalR</b>:进程内 hub 推送给已认证的在线连接,无第三方 / 公网 egress;
+    /// 这是当前唯一会**真实投递**并因此**写一条投递审计日志**的渠道
+    /// (无在线连接时 pusher 早退返回 Ok(0),仍写一条 targetCount=0 的日志)。</para>
+    ///
+    /// <para>故 <c>notify_channel = "log,SignalR"</c> 每次派发恰好落 <b>1</b> 条日志(SignalR 那条),
+    /// log 贡献 0 条。</para>
+    ///
+    /// 刻意不含 Email / Sms / DingTalk / WorkWeixin:这四个渠道当前在 ApprovalFlow.json 全为 false、
+    /// ApprovalFlowNotifyConfig 表 0 行,属「就位不启用」;暴露为可选项会造成「看似可用」,
+    /// 且 push adapter 在 cfg == null 时会跳过开关检查放行已注册 pusher,届时即成真实外发。
+    /// 要启用须先完成渠道凭据与开关的独立评审。
+    /// </summary>
+    private static readonly string[] AllowedChannels = { "log", "SignalR" };
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:场景词表校验。
+    ///
+    /// 新建一律要求 canonical 单模块场景 S1–S7(判定复用 <see cref="S8ModuleCode.IsValid"/>,
+    /// 它是仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。
+    /// 原先服务端对 scene_code **零词表校验**,任意字符串都能入库;
+    /// 而前端下拉来自租户自建的 ado_s8_scene_config 且开着 allow-create,等于对外开放自由文本。
+    ///
+    /// 编辑时若 scene 与库中现值**逐字相同**则放行:库里存在 S2S6_PRODUCTION、S8_DEMO_DEFAULT 等
+    /// 历史行,若无条件强制 canonical,这些行会变成「连改渠道都保存不了」的砖块。
+    /// 判据刻意用「与现值逐字相等」而非「在 legacy 常量表内」——S8_DEMO_DEFAULT 不在任何常量表里。
+    /// </summary>
+    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));
+    }
+
+    /// <summary>
+    /// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:严重度词表校验,**必须在 Normalize 之前调用**。
+    ///
+    /// S8SeverityCode.Normalize 的兜底分支是 <c>_ =&gt; Follow</c>:任何垃圾串都会被静默降级成 FOLLOW
+    /// 而不报错——用户把 SERIOUS 拼错就会得到一条「保存成功但严重度悄悄变了」的配置。
+    /// 因此这里先做严格判定再归一。
+    ///
+    /// 也刻意**不复用** S8SeverityCode.IsValid:那是宽松版,
+    /// FOLLOW / SERIOUS / LOW / MEDIUM / HIGH / CRITICAL 六值全放行(供 legacy 查询参数兼容用),
+    /// 拿来当写入门禁会直接放过 legacy 值。
+    ///
+    /// 编辑时同样允许「与库中现值逐字相同」的 legacy 值通过,理由同 <see cref="ValidateSceneCode"/>。
+    /// </summary>
+    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));
+    }
+
     public async Task<List<object>> GetRoleOptionsAsync()
     {
         var roles = await _roleRep.AsQueryable()
@@ -42,8 +169,14 @@ public class S8NotificationLayerService : ITransient
             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()
@@ -65,8 +198,14 @@ public class S8NotificationLayerService : ITransient
             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);