Parcourir la source

refactor(s8): 从 Runtime 退役旧通知分层

通知收件人从此只有一个来源:ado_s8_notification_recipient
(Rule + Event + RecipientType → 责任池 / 当前处理人 / 当前复核人 → SysUser)。
没配置就是不发,不再有第二套 (scene, severity, level) 语义在背后接管。

切断 fallback
  S8NotificationLayerResolver 不再读 ado_s8_notification_layer。
  为什么整段删而不是留个开关:新旧两套按完全不同的维度选人,留着它
  「新模型没配」就会静默走另一套语义,而管理员在规则弹窗里根本看不到那批人。
  退役后缺口以「0 收件人 + Warning」暴露,而不是被旧配置悄悄兜住。
  既有的 fail-loud 语义原样保留:配了但解析出 0 人仍是落 Warning 后返回,
  本批不重构这一点。

停止 Seed
  S8NotificationLayerSeedData.HasData() 恒返回空集,不再灌那 14 条平台默认。
  类保留(反射发现,空集即不供数,是切断供给的最小改动),物理删除留给 P1-C3。
  刻意不保留原 scene→role 映射表:留着等于给后来者一个"看起来可以恢复"的入口,
  而旧语义与新模型没有正确的迁移路径。需要考据时查 git 历史。

两处守卫按新决定改写(未删)
  NewModel_TakesPrecedence_AndLegacyIsOnlyFallback 从「新模型命中时旧表不参与」
  收紧为「旧表任何情况都不参与」;NotificationLayer_BackendFallback_IsFullyKept
  翻转前半段为「Runtime 消费归零」,后半段仍守「后端定义不得被误删」。
  保护意图都没变 —— 两套语义绝不能同时作用于一次派发。

新增 S8LegacyNotifyDecommissionTests 15 项,其中 T4 是核心:
即便旧表里躺着能匹配的数据,NEW miss 时也绝不会去用它。

已知缺口(本批明确不修):主动提报的异常 SourceRuleCode 为 NULL,
新模型以 rule_code='*' 为键查找,而 '*' 目前没有任何业务写入路径 ——
这类异常的后续事件通知暂时无收件人来源。该缺口由「主动提报通知契约」
独立梳理,T12 守住它不被两种走偏解决:塞假 RuleCode,或把 fallback 接回来。

旧数据的正式退役升级脚本本批未创建:1.0.504 已被并行实例占用,
csproj 也正被其修改,取号会覆盖他人工作。详见回执。
YY968XX il y a 2 jours
Parent
commit
e0b3f73cc7

+ 10 - 4
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ConfigConsolidationTests.cs

@@ -130,16 +130,22 @@ public class S8ConfigConsolidationTests
     }
 
     /// <summary>
-    /// <b>后端 fallback 必须原封不动</b>:本批退役的是入口,不是能力。
-    /// 旧表仍是新模型未配置时的兜底,删了会让现网既有通知直接静默失效。
+    /// 后端<b>定义</b>保留,但 Runtime<b>消费</b>已归零。
+    ///
+    /// <para><b>本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 翻转了一半</b>:
+    /// P0-C1 只退 UI 入口、后端 fallback 必须留着;P0-C2 明确决定切断 Runtime 回落。
+    /// 因此现在要同时守两件事——① 派发入口不得再读旧表(下一条断言);
+    /// ② Entity / Service / Controller / Seed 类<b>仍在原地</b>,
+    /// 物理删除属 backend dead code 清理,留给 P1-C3,不与 Runtime 退役混批。</para>
     /// </summary>
     [Fact]
     public void NotificationLayer_BackendFallback_IsFullyKept()
     {
         var resolver = File.ReadAllText(Path.Combine(
             PluginRoot, "Service", "S8", "S8NotificationLayerResolver.cs"));
-        Assert.Contains("AdoS8NotificationLayer", resolver);
-        Assert.Contains("S8NotificationLayerMerge.Effective", resolver);
+        // Runtime 消费归零
+        Assert.DoesNotContain("AdoS8NotificationLayer", resolver);
+        Assert.DoesNotContain("S8NotificationLayerMerge", resolver);
 
         foreach (var kept in new[]
                  {

+ 232 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacyNotifyDecommissionTests.cs

@@ -0,0 +1,232 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-LEGACY-NOTIFY-DECOMMISSION-1:旧通知分层的 Runtime 退役证明。
+///
+/// <para><b>本文件要钉死的一件事</b>:通知收件人从此<b>只有一个来源</b> ——
+/// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType)。
+/// 没配置就是不发;<b>不会</b>有第二套 (scene, severity, level) 语义在背后接管。</para>
+///
+/// <para><b>为什么这值得一整个文件的守卫</b>:双 Authority 是本模块最难自查的故障形态。
+/// 管理员在规则弹窗里把收件人删干净,通知却照发 —— 因为旧表还在兜底,
+/// 而那批人在新页面上根本看不见。退役之后,缺口会以「0 收件人 + Warning」暴露出来,
+/// 而不是被悄悄补上。<b>后来者若想恢复 fallback「让测试变绿」,请先读这段。</b></para>
+/// </summary>
+public class S8LegacyNotifyDecommissionTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string Read(string relative)
+    {
+        var full = Path.Combine(PluginRoot, relative.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    /// <summary>去注释 —— 注释里提到某个词不等于代码在用它。</summary>
+    private static string CodeOnly(string relative) =>
+        string.Join('\n', Read(relative).Split('\n').Where(l =>
+        {
+            var t = l.TrimStart();
+            return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
+        }));
+
+    private const string Dispatcher = "Service/S8/S8NotificationLayerResolver.cs";
+    private const string Recipient = "Service/S8/S8NotificationRecipientResolver.cs";
+    private const string LegacySeed = "SeedData/S8NotificationLayerSeedData.cs";
+
+    // ══════════════════════ T1–T4 · 退役核心 ══════════════════════
+
+    /// <summary>T1:NEW 命中 → 正常解析并发送。</summary>
+    [Fact]
+    public void T1_NewRecipientHit_IsDispatched()
+    {
+        var code = CodeOnly(Dispatcher);
+        Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
+        Assert.Contains("await DispatchToUsersAsync(input, resolution);", code);
+    }
+
+    /// <summary>
+    /// T2:NEW 不存在 → <b>不得调用 Legacy resolver</b>,直接返回。
+    /// <para>这是退役的核心断言:miss 之后没有第二条路。</para>
+    /// </summary>
+    [Fact]
+    public void T2_NewRecipientMiss_DoesNotCallLegacy()
+    {
+        var code = CodeOnly(Dispatcher);
+        Assert.Contains("if (resolution.Source == \"NONE\")", code);
+
+        // NONE 分支之后到方法结束之间,不得出现任何旧表读取
+        var idx = code.IndexOf("if (resolution.Source == \"NONE\")", StringComparison.Ordinal);
+        Assert.True(idx > 0);
+        var tail = code[idx..];
+        foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "layers" })
+            Assert.DoesNotContain(legacy, tail);
+    }
+
+    /// <summary>
+    /// T3:NEW 配了但解析出 0 人 → 保持既有 fail-loud(Warning + return),<b>不回落</b>。
+    /// <para>本批刻意<b>不重构</b>这一点:0 收件人必须可观测,而不是被旧配置补上。</para>
+    /// </summary>
+    [Fact]
+    public void T3_ConfiguredButEmpty_StaysFailLoudWithoutFallback()
+    {
+        var code = CodeOnly(Dispatcher);
+        Assert.Contains("resolution.UserIds.Count == 0", code);
+        Assert.Contains("S8RecipientDispatch: resolved 0 recipients", code);
+
+        // ConfiguredButEmpty 语义在收件人解析侧同样保留
+        var recipient = CodeOnly(Recipient);
+        Assert.Contains("ConfiguredButEmpty", recipient);
+        Assert.Contains("s8_notify_recipients_empty", recipient);
+    }
+
+    /// <summary>
+    /// T4:<b>本批最关键的退役证明</b> —— 即便旧表里躺着能匹配的数据,
+    /// NEW miss 时也绝不会去用它。
+    ///
+    /// <para>判据是「派发入口对旧表零引用」而不是「查询条件变了」:
+    /// 只要还留着一行查询,将来某次「顺手加个开关」就能把它接回来。</para>
+    /// </summary>
+    [Fact]
+    public void T4_LegacyRowsAreNeverConsultedEvenWhenTheyWouldMatch()
+    {
+        var code = CodeOnly(Dispatcher);
+        foreach (var legacy in new[]
+                 {
+                     "AdoS8NotificationLayer", "S8NotificationLayerMerge",
+                     "_layerRep", "_roleResolver", "SceneCode == input.SceneCode",
+                     "x.Severity == severity", "S8ConfigScope.GlobalTenantId",
+                 })
+            Assert.DoesNotContain(legacy, code);
+    }
+
+    /// <summary>T5:Legacy Seed 不再生成任何分层数据。</summary>
+    [Fact]
+    public void T5_LegacySeed_ProducesNothing()
+    {
+        var seed = CodeOnly(LegacySeed);
+        Assert.Contains("HasData() => Array.Empty<AdoS8NotificationLayer>()", seed);
+
+        // 原来的 scene→role 映射必须整体消失,否则等于给恢复留了入口
+        foreach (var gone in new[]
+                 {
+                     "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_QC",
+                     "ROLE_WH_INBOUND", "ROLE_PURCHASER", "L1_OPERATOR", "sceneRoleMap",
+                 })
+            Assert.DoesNotContain(gone, seed);
+    }
+
+    // ══════════════════════ T6–T9 · 新链路不得回归 ══════════════════════
+
+    /// <summary>T6–T9:五种 RecipientType 的解析全部保留。</summary>
+    [Theory]
+    [InlineData("HandlerPool", "_handlerPool.GetMemberIdsAsync")]
+    [InlineData("Assignee", "e.AssigneeUserId is > 0")]
+    [InlineData("Reviewer", "e.VerifierUserId is > 0")]
+    [InlineData("EscalationPool", "S8ResponsibilityType.Escalation")]
+    [InlineData("SpecificUser", "S8RoleResolver.SplitTokens(row.RecipientUserIds)")]
+    public void T6_T9_AllRecipientTypesStillResolve(string type, string marker)
+    {
+        var code = CodeOnly(Recipient);
+        Assert.Contains($"case S8RecipientType.{type}:", code);
+        Assert.Contains(marker, code);
+    }
+
+    /// <summary>HANDLER_POOL / ESCALATION_POOL 仍从 Rule 责任池解析,未被改成别的来源。</summary>
+    [Fact]
+    public void ResponsibilityPools_RemainTheSourceForPoolRecipients()
+    {
+        var code = CodeOnly(Recipient);
+        Assert.Contains("IS8RuleHandlerPoolReader", code);
+        Assert.Contains("IS8RuleResponsibilityReader", code);
+        Assert.Contains("_pools.GetMemberIdsAsync(", code);
+
+        // 绝不从旧 target_role / L1-L3 推责任
+        foreach (var banned in new[] { "TargetRoleIds", "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" })
+            Assert.DoesNotContain(banned, code);
+    }
+
+    /// <summary>T10:租户隔离不回归 —— 收件人最终仍过一遍同租户 + 启用校验。</summary>
+    [Fact]
+    public void T10_TenantIsolationNotRegressed()
+    {
+        var code = CodeOnly(Recipient);
+        Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code);
+        Assert.Contains("x.TenantId == tenantId", code);
+
+        var dispatcher = CodeOnly(Dispatcher);
+        Assert.Contains("input.TenantId", dispatcher);
+    }
+
+    /// <summary>
+    /// T11:legacy backend 定义<b>仍在</b>(P1-C3 才清),但 Runtime 消费者 = 0。
+    /// </summary>
+    [Fact]
+    public void T11_LegacyBackendDefinitionsKept_ButZeroRuntimeConsumer()
+    {
+        foreach (var kept in new[]
+                 {
+                     "Entity/S8/AdoS8NotificationLayer.cs",
+                     "Service/S8/S8NotificationLayerService.cs",
+                     "Service/S8/S8NotificationLayerMerge.cs",
+                     "Controllers/S8/AdoS8ConfigNotificationLayersController.cs",
+                     "SeedData/S8NotificationLayerSeedData.cs",
+                 })
+            Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
+                $"legacy backend 定义被误删(应留给 P1-C3):{kept}");
+
+        // 唯一的通知派发入口对旧表零引用
+        Assert.DoesNotContain("AdoS8NotificationLayer", CodeOnly(Dispatcher));
+    }
+
+    /// <summary>
+    /// T12:<b>人工提报未被擅自改成 '*' 方案</b>。
+    ///
+    /// <para>已知缺口(本批明确不修):主动提报的异常 <c>SourceRuleCode</c> 为 NULL,
+    /// 新模型会以 <c>rule_code='*'</c> 为键查找,而 <c>'*'</c> 目前<b>没有任何业务写入路径</b>。
+    /// fallback 切断后,这类异常的后续事件通知暂时无收件人来源。</para>
+    ///
+    /// <para>该缺口由「主动提报通知契约」独立梳理。本守卫防的是两种走偏:
+    /// ① 为了补缺口而改 Manual Report 去塞一个假 RuleCode;
+    /// ② 为了让测试变绿而把 legacy fallback 接回来。</para>
+    /// </summary>
+    [Fact]
+    public void T12_ManualReportContract_IsUntouchedAndGapIsAcknowledged()
+    {
+        var manual = CodeOnly("Service/S8/S8ManualReportService.cs");
+
+        // 手工建单仍不写 SourceRuleCode —— 没有被塞进一个假规则码
+        var createIdx = manual.IndexOf("SourceType = \"MANUAL\"", StringComparison.Ordinal);
+        Assert.True(createIdx > 0, "未找到手工提报的建单分支");
+        var block = manual[Math.Max(0, createIdx - 1500)..Math.Min(manual.Length, createIdx + 1500)];
+        Assert.DoesNotContain("SourceRuleCode =", block);
+
+        // 也没有借机新增 '*' 的写入能力
+        var configSvc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
+        Assert.DoesNotContain("TenantDefaultRuleCode", configSvc);
+
+        // '*' 仍然只有读侧引用(resolver 内),没有任何写入者
+        Assert.Contains("TenantDefaultRuleCode", CodeOnly(Recipient));
+    }
+
+    /// <summary>
+    /// 目录仍是 PROVISIONING 定位,没有被悄悄升格成 Runtime Authority。
+    /// <para>判据:派发链路(入口 + 收件人解析)都不查 <c>DefaultRecipientByEvent</c>。</para>
+    /// </summary>
+    [Fact]
+    public void DefaultRecipientCatalog_StaysProvisioningOnly()
+    {
+        foreach (var runtime in new[] { Dispatcher, Recipient })
+            Assert.DoesNotContain("DefaultRecipientByEvent", CodeOnly(runtime));
+
+        // 目录本身与写入侧仍在
+        Assert.Contains("DefaultRecipientByEvent", Read("Const/S8/S8NotificationCatalog.cs"));
+        Assert.Contains("ResolveDefaultRecipientType", Read("Service/S8/S8NotificationRecipientConfigService.cs"));
+        Assert.Equal(7, S8NotificationCatalog.Events.Count);
+    }
+}

+ 13 - 5
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8NotificationRecipientTests.cs

@@ -114,19 +114,27 @@ public class S8NotificationRecipientTests
     // ══════════════════════════════════════════════════════════════════════
 
     /// <summary>
-    /// 新模型有配置 → 旧分层<b>完全不参与</b>;两者不得合并
+    /// 新模型是收件人的<b>唯一</b>来源;旧分层完全不参与
     ///
-    /// <para>合并的后果:管理员删掉收件人之后通知照发,而他在新页面上已经看不到那些人。</para>
+    /// <para><b>本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 收紧</b>:
+    /// 原先要守的是「新模型有配置时旧表不得参与」(旧表仍是 miss 时的 fallback);
+    /// 本批把 fallback 整体切断后,要守的变成「旧表在<b>任何</b>情况下都不参与」。
+    /// 保护意图不变——两套语义绝不能同时作用于一次派发。</para>
     /// </summary>
     [Fact]
     public void NewModel_TakesPrecedence_AndLegacyIsOnlyFallback()
     {
         var code = CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
 
-        // 新模型先判,命中即 return,不落到旧表查询
+        // 唯一来源:新模型
         Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
-        Assert.Contains("if (resolution.Source != \"NONE\")", code);
-        Assert.Contains("await DispatchToUsersAsync(input, resolution);\n\t\t\t\treturn;", code.Replace("\r\n", "\n"));
+
+        // 未配置就是不发,不再有第二套语义接管
+        Assert.Contains("if (resolution.Source == \"NONE\")", code);
+
+        // 派发入口里不得再出现任何旧分层的读取
+        foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "_roleResolver" })
+            Assert.DoesNotContain(legacy, code);
 
         // 规则级与租户级默认也不合并(后者只在前者为空时使用)
         var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");

+ 19 - 62
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8NotificationLayerSeedData.cs

@@ -3,73 +3,30 @@ using Admin.NET.Plugin.AiDOP.Entity.S8;
 namespace Admin.NET.Plugin.AiDOP;
 
 /// <summary>
-/// S8-NOTIFICATION-LAYER-BASELINE-1:S1-S7 × FOLLOW/SERIOUS × L1_OPERATOR 最小通知分层 baseline。
-/// 灌入 ado_s8_notification_layer,使 S8NotificationLayerResolver 在任意场景命中后可解析到合法可达 RoleCode。
+/// S8-LEGACY-NOTIFY-DECOMMISSION-1:**本 Seed 已停止供数,恒返回空集。**
 ///
-/// 设计要点:
-/// - tenant_id=0 / factory_id=0 全局基线:与 S8NotificationLayerResolver baseline fallback 一致(先精确租工厂匹配,
-///   不命中再退到 0/0),覆盖所有租户/工厂,避免逐工厂复制。
-/// - 仅使用当前可达的 ROLE_* 角色(owner 端 7 角色已全部 SysUser+EmployeeMaster 1:1 可达);
-///   不使用 S8_01/S8_02/S8_03(当前 0 用户),避免 0 解析。
-/// - level_code 仅 L1_OPERATOR;L2/L3 等真实组织层级待 S8_* 角色绑定 + 业务确认后扩展。
-/// - notify_channel="log":经代码取证 S8NotificationPushAdapter 不存在 Channel="log" 的注册 pusher,
-///   wanted 集合内仅"log" → 无任何外部 pusher 触发;保留 row 让 resolver 可命中、便于后续 channel 切换。
-/// - 不写 enabled/is_deleted(实体与 DB 均无该列,行存在即视为可用)。
+/// <para><b>它原来做什么</b>:向 <c>ado_s8_notification_layer</c> 灌 14 条平台默认基线
+/// (S1–S7 × FOLLOW/SERIOUS × <c>L1_OPERATOR</c>,<c>tenant_id=0 / factory_id=0</c>),
+/// 让旧的 <c>(scene, severity, level)</c> 分层通知在任意场景都能解析到一个可达 RoleCode。</para>
 ///
-/// 模块→角色映射(基于现有 ROLE_* 业务归属,与 exception_type owner 配置一致):
-///   S1 / S7  → ROLE_ORDER_PLANNER(订单 / 交付)
-///   S2 / S6  → ROLE_PRODUCTION_PLANNER(制造 / 工单)
-///   S3       → ROLE_QC(来料质检)
-///   S4       → ROLE_WH_INBOUND(仓储入库)
-///   S5       → ROLE_PURCHASER(采购)
+/// <para><b>为什么停</b>:通知收件人的 Current Authority 已经是
+/// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType → 责任池 / 当前处理人 /
+/// 当前复核人 → SysUser)。旧分层的 Runtime 消费者在本批被整体切断,
+/// 继续灌这 14 条只会:① 让退役后的库里重新长出没人消费的数据;
+/// ② 让「删干净了」这件事在下一次启动后失效。</para>
 ///
-/// Id 区间:1329908200001 起(与 exception_type 1329908100000 区段不冲突),稳定可重灌。
+/// <para><b>为什么保留类而不是删掉</b>:<c>ISqlSugarEntitySeedData&lt;T&gt;</c> 由反射发现,
+/// 返回空集就等于不供数,这是切断供给的<b>最小改动</b>;类、Entity、CRUD 的物理删除
+/// 属于 backend dead code 清理,留给 P1-C3 单独一批做,不与 Runtime 退役混批。</para>
+///
+/// <para><b>刻意不保留原映射表</b>:把 <c>S1→ROLE_ORDER_PLANNER</c> 那张表以注释或死代码形式留着,
+/// 等于给后来者一个「看起来可以恢复」的入口。旧语义按 (scene, severity, level) 选人,
+/// 与新模型的 (rule, event, responsibility) 是两套互不相干的维度,
+/// <b>不存在把它迁过去的正确做法</b>。需要考据时查 git 历史。</para>
 /// </summary>
 [IncreSeed]
 public class S8NotificationLayerSeedData : ISqlSugarEntitySeedData<AdoS8NotificationLayer>
 {
-    public IEnumerable<AdoS8NotificationLayer> HasData()
-    {
-        var ct = DateTime.Parse("2026-05-02 00:00:00");
-        const long baseId = 1329908200000L;
-
-        // (sceneCode, ownerRoleCode)
-        var sceneRoleMap = new (string Scene, string Role)[]
-        {
-            ("S1", "ROLE_ORDER_PLANNER"),
-            ("S2", "ROLE_PRODUCTION_PLANNER"),
-            ("S3", "ROLE_QC"),
-            ("S4", "ROLE_WH_INBOUND"),
-            ("S5", "ROLE_PURCHASER"),
-            ("S6", "ROLE_PRODUCTION_PLANNER"),
-            ("S7", "ROLE_ORDER_PLANNER"),
-        };
-
-        var severities = new[] { "FOLLOW", "SERIOUS" };
-        var levelCode = "L1_OPERATOR";
-        var channel = "log,SignalR";
-
-        long id = baseId;
-        var list = new List<AdoS8NotificationLayer>();
-        foreach (var (scene, role) in sceneRoleMap)
-        {
-            foreach (var sev in severities)
-            {
-                id++;
-                list.Add(new AdoS8NotificationLayer
-                {
-                    Id = id,
-                    TenantId = 0,
-                    FactoryId = 0,
-                    SceneCode = scene,
-                    Severity = sev,
-                    LevelCode = levelCode,
-                    TargetRoleIds = role,
-                    NotifyChannel = channel,
-                    CreatedAt = ct,
-                });
-            }
-        }
-        return list;
-    }
+    /// <summary>恒空。任何启动 / provisioning / 升级路径都不会再生成平台默认分层行。</summary>
+    public IEnumerable<AdoS8NotificationLayer> HasData() => Array.Empty<AdoS8NotificationLayer>();
 }

+ 31 - 109
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -21,31 +21,25 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 /// 不接入 watch / scheduler / task 主链路;不写 ApprovalFlowNotifyLog;
 /// 不修改任何 schema;任何分支异常仅 LogWarning,不向上抛。
 ///
-/// 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 共用同一实现,禁止各自复制
+/// S8-LEGACY-NOTIFY-DECOMMISSION-1:**LEGACY 分层已从 Runtime 退役。**
+/// 本类不再读取 <c>ado_s8_notification_layer</c>,收件人唯一来源是
+/// <c>ado_s8_notification_recipient</c>(Rule + Event + RecipientType)
+/// 类名保留 "Layer" 只为不牵动全部调用方;它现在是<b>通知派发入口</b>,与分层无关
+/// 旧表 / Entity / CRUD / Seed 类仍存在但已无 Runtime 消费者,物理清理见 P1-C3
 /// </summary>
 public class S8NotificationLayerResolver : ITransient
 {
-	private readonly SqlSugarRepository<AdoS8NotificationLayer> _layerRep;
-	private readonly S8RoleResolver _roleResolver;
 	private readonly IS8NotificationRecipientResolver _recipientResolver;
 	private readonly Admin.NET.Core.Service.SysNoticeService _noticeService;
 	private readonly S8NotificationPushAdapter _pushAdapter;
 	private readonly ILogger<S8NotificationLayerResolver> _logger;
 
 	public S8NotificationLayerResolver(
-		SqlSugarRepository<AdoS8NotificationLayer> layerRep,
-		S8RoleResolver roleResolver,
 		IS8NotificationRecipientResolver recipientResolver,
 		Admin.NET.Core.Service.SysNoticeService noticeService,
 		S8NotificationPushAdapter pushAdapter,
 		ILogger<S8NotificationLayerResolver> logger)
 	{
-		_layerRep = layerRep;
-		_roleResolver = roleResolver;
 		_recipientResolver = recipientResolver;
 		_noticeService = noticeService;
 		_pushAdapter = pushAdapter;
@@ -118,116 +112,44 @@ public class S8NotificationLayerResolver : ITransient
 			return;
 		}
 
-		// ── S8-NOTIFY-RECIPIENT-1:新收件人模型是 CURRENT AUTHORITY ──
+		// ── S8-LEGACY-NOTIFY-DECOMMISSION-1:LEGACY 分层已从 Runtime 彻底退役 ──
 		//
-		// 新模型对该 (租户, 规则, 事件) 一旦有任何配置,旧分层表就**完全不参与**本次派发。
-		// 不做"两者合并":合并会让管理员删掉收件人之后通知照发,而他在新页面上
-		// 已经看不到那些人了 —— 那是最难自查的一类配置故障。
+		// **本方法不再读取 ado_s8_notification_layer。** 通知收件人的唯一来源是
+		// Rule-centric 新模型(ado_s8_notification_recipient):
+		//     Event → RecipientType → 责任池 / 当前处理人 / 当前复核人 → SysUser
 		//
-		// 只有新模型没有配置时才回落 LEGACY 分层,保证现网既有通知不被这批改动打断。
-		if (!string.IsNullOrWhiteSpace(input.EventCode) && input.ExceptionRef != null)
-		{
-			var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef);
-			if (resolution.Source != "NONE")
-			{
-				await DispatchToUsersAsync(input, resolution);
-				return;
-			}
-		}
-
-		// S8-STEP6C-CFG-NOTIFY-FIX-AND-CERT-1:覆盖粒度由「整个 (scene, severity) 键」收敛为**逐 level 覆盖**。
+		// 为什么整段删掉而不是留个开关:旧链路按 (scene, severity, level) 选人,
+		// 与新模型的 (rule, event, responsibility) 是两套互不相干的维度。留着它,
+		// 「新模型没配」就会静默走另一套语义,而管理员在规则弹窗里根本看不到那批人 ——
+		// 这正是本模块反复出现的失败形态。退役后语义变成:**没配就是没配**,
+		// 缺口会以 0 收件人的形式暴露出来,而不是被旧配置悄悄兜住。
 		//
-		// 原实现是两次往返 + 整键 fallback(精确租户查到任意一行 → 完全不读 (0,0))。
-		// 那意味着工厂只要为某一层建一行,平台默认在该 scene+severity 下的其余层级会整体消失——
-		// 工厂本意是「补一层」,实际是「换掉整组」,且该副作用在配置页上不可见。
+		// 刻意<b>不动</b>既有的 fail-loud 语义:新模型查到配置但解析出 0 人时,
+		// 仍由 DispatchToUsersAsync 落 Warning 后返回,不做任何回落。
 		//
-		// 现在一次 OR 查询同时取回平台默认与本工厂行,再由 S8NotificationLayerMerge 逐 level 取舍。
-		// merge 与配置页 ListAsync 共用同一函数,杜绝「页面显示一种、运行时派发另一种」。
-		List<AdoS8NotificationLayer> layers;
-		var severity = S8SeverityCode.Normalize(input.Severity);
-		try
+		// 已知缺口(本批明确不修,见 P0-C2 回执):人工主动提报的异常
+		// SourceRuleCode 为 NULL,新模型会以 rule_code='*' 为键查找,而 '*' 目前
+		// 没有任何业务写入路径 —— 这类异常的后续事件通知暂时无收件人来源。
+		// 该缺口由「主动提报通知契约」独立梳理,**不以恢复 LEGACY 的方式兜底**。
+		if (string.IsNullOrWhiteSpace(input.EventCode) || input.ExceptionRef == null)
 		{
-			var candidates = await _layerRep.AsQueryable()
-				.Where(x => x.SceneCode == input.SceneCode && x.Severity == severity)
-				// S8-TENANT-ONLY-BATCH6:候选判据去掉 factory 等值,保留 (0,0) 平台默认哨兵。
-				// 不去掉是必然断链:本批之后异常 factory_id 恒为 0,而库里的 notification_layer
-				// 租户行 factory_id ≠ 0,租户那一支永远命中不了 —— 分层通知会静默退化成"只有平台默认"。
-				.Where(x => x.TenantId == input.TenantId
-					|| (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId))
-				.ToListAsync();
-
-			layers = S8NotificationLayerMerge.Effective(candidates);
-		}
-		catch (Exception ex)
-		{
-			_logger.LogWarning(ex, "S8LayerDispatch: query AdoS8NotificationLayer failed (scene={Scene}, sev={Sev})", input.SceneCode, input.Severity);
+			_logger.LogInformation(
+				"S8NotifyDispatch skip: 缺少 eventCode 或 exception 引用,无法解析新模型收件人 (exceptionId={ExceptionId} event={Event})",
+				input.ExceptionId, input.EventCode);
 			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)
+		var resolution = await _recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef);
+		if (resolution.Source == "NONE")
 		{
-			_logger.LogInformation("S8LayerDispatch: no layer matched (tenant={Tenant}, factory={Factory}, scene={Scene}, sev={Sev})",
-				input.TenantId, input.SceneCode, input.Severity);
+			// 没有配置就是没有配置 —— 不再有第二套语义接管。
+			_logger.LogInformation(
+				"S8NotifyDispatch skip: 该 (租户,规则,事件) 未配置收件人,不发送 (tenant={Tenant} rule={Rule} event={Event} exceptionId={ExceptionId})",
+				input.TenantId, input.ExceptionRef.SourceRuleCode, input.EventCode, input.ExceptionId);
 			return;
 		}
 
-		var notification = BuildNotification(input);
-
-		foreach (var layer in layers)
-		{
-			List<long> 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;
-				}
-				// S8-ROLE-TENANT-SCOPE-1:显式透传租户作为安全边界。
-				// 注意用 input.TenantId 而非 layer.TenantId —— 全局层(tenant_id=0)也必须
-				// 按**触发异常的租户**解析收件人,否则全局层会解析不到任何角色。
-				userIds = await _roleResolver.ResolveUserIdsAsync(input.TenantId, 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,
-					// 通知日志的 factory 列写兼容值 0:归属只由 tenant 决定。
-					factoryId: S8ConfigScope.GlobalFactoryId,
-					exceptionId: input.ExceptionId,
-					userIds: userIds,
-					notification: notification,
-					channels: channels);
-			}
-			catch (Exception ex)
-			{
-				// PushAdapter 自身已对内部错误做了捕获;这里再兜一层保证主流程不被打断。
-				_logger.LogWarning(ex, "S8LayerDispatch: push throw (layer id={LayerId})", layer.Id);
-			}
-		}
+		await DispatchToUsersAsync(input, resolution);
 	}
 
 	/// <summary>