Jelajahi Sumber

fix(s8): make notification roles tenant-safe | server 1.0.429

CFG_NOTIFY 由 PARTIAL 转 READY(20 PASS + 0 N/A + 0 FAIL)。

根因:默认租户的物理 SysRole.Id 被当成了角色候选的身份依据。
GetRoleOptionsAsync() 按 Id BETWEEN 1329908000101..107 外加 1300000000101 筛选,
而 AidopTenantInitService 是按租户各复制一份 S8 业务角色的——同 Code、不同 Id
(实测 9 个租户各有全部 7 个 canonical 角色)。于是在任何非默认租户下,
SysRole 仓储的租户过滤把这些 Id 全部滤掉,候选恒为空,
配置页无法查看或编辑通知分层的目标角色。
运行时不受影响:S8RoleResolver 按 Code 而非 Id 解析,故通知派发一直是好的——
这是配置面缺陷,不是运行时缺陷。

修复:按 canonical Role Code 白名单 + 当前可信租户过滤。
不显式传 tenantId,租户边界仍由 SqlSugarRepository<SysRole> 的全局过滤按认证身份施加,
为这一个方法另开旁路反而会绕过可信作用域。
出参 value 仍是 Code——ado_s8_notification_layer.target_role_ids 存的就是 Code,
改成物理 Id 会当场废掉既有配置与 resolver 的解析口径;
新增的 id 仅供追溯(证明选中的是本租户物理行),形状向后兼容。
展示序由 canonical OrderNo 决定而非物理 Id,否则同一份配置在不同租户会呈现不同顺序。
缺失行为:白名单里某个 Code 在当前租户不存在时只是不出现该项,
不回落默认租户、不自动建角色、不报错。

顺带消除分叉隐患:这 7 个角色此前在 S8SysRoleSeedData 与 AidopTenantInitService
各存一份逐字相同的字面量、靠人工同步,role-options 若再复制第三份必然分叉。
本批合并为唯一来源 S8CanonicalRole,两处改为引用,内容(Code / 显示名 / OrderNo)未变。
未向任何租户 seed sys_admin 或其它角色:sys_admin 是平台系统角色,
不在任一 canonical 定义中,14 条生效层零处引用,
仓内唯一含它的通知层属另一个租户的 legacy demo 行且其场景已被禁止新建。

Runtime 认证实测:role-options 0 → 7;默认租户 Id 泄漏 0;跨租户泄漏 0;
Effective Notify 保持 14(覆盖语义复验:不同 level 共存、同 level 覆盖、删除回落 GLOBAL);
渠道仍仅 log / SignalR,Email 与 severity=HIGH 均 400;
runtime resolver 与 dispatch 四文件零 diff;
配置往返 create/readback/update/delete 全 200、临时残留 0;
Golden 14 / UAT exceptions 17 / Watch 5-0 / Detection 8 / 真实外发 0 全程零漂移。
YY968XX 17 jam lalu
induk
melakukan
9367caf999

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

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

+ 192 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8CanonicalRoleOptionsTests.cs

@@ -0,0 +1,192 @@
+using System.Reflection;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1 的回归锁。
+///
+/// <para>缺陷本体:<c>GetRoleOptionsAsync</c> 曾按**默认租户的物理 SysRole.Id** 段筛选,
+/// 于是任何非默认租户的角色候选恒为空。修复后必须按 **canonical Role Code** 在可信租户
+/// 作用域内筛选。本文件把「Code 是逻辑身份、Id 是租户本地物理身份」这条契约钉死。</para>
+/// </summary>
+public class S8CanonicalRoleOptionsTests
+{
+    // 真实数据形态:同一 Code 在每个租户各有一行,Id 互不相同。
+    private const long DefaultTenantOrderPlannerId = 1329908000101L; // 默认租户
+    private const long UatTenantOrderPlannerId = 838257188253765L;   // UAT 租户
+    private const long ThirdTenantOrderPlannerId = 797403760988229L; // 第三租户
+
+    // ── canonical 定义本身 ────────────────────────────────────────────────
+
+    /// <summary>canonical 集合必须正好是这 7 个业务角色码,顺序即产品展示序。</summary>
+    [Fact]
+    public void Canonical_IsExactlyTheSevenBusinessRoles()
+    {
+        Assert.Equal(
+            new[]
+            {
+                "ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_PURCHASER",
+                "ROLE_WH_INBOUND", "ROLE_WH_OUTBOUND", "ROLE_QC", "ROLE_EQUIP_MAINT",
+            },
+            S8CanonicalRole.BusinessRoleCodes);
+    }
+
+    /// <summary>
+    /// <c>sys_admin</c> 是平台系统角色,不在 S8 业务角色集内——
+    /// 它不被任何一条生效通知层引用,不得混进租户的角色候选。
+    /// </summary>
+    [Fact]
+    public void Canonical_DoesNotContainPlatformSysAdmin()
+    {
+        Assert.DoesNotContain("sys_admin", S8CanonicalRole.BusinessRoleCodes);
+    }
+
+    /// <summary>canonical 集合只能承载**逻辑身份**:任何 Code 都不得长得像物理 Id。</summary>
+    [Fact]
+    public void Canonical_CarriesNoTenantSpecificIdentity()
+    {
+        foreach (var code in S8CanonicalRole.BusinessRoleCodes)
+            Assert.False(long.TryParse(code, out _), $"canonical code '{code}' 不得是数字物理 Id");
+    }
+
+    /// <summary>两个既有定义必须与 canonical 同源,否则又会人工分叉。</summary>
+    [Fact]
+    public void Canonical_IsTheSingleSourceForTenantProvisioning()
+    {
+        var field = typeof(Admin.NET.Plugin.AiDOP.Infrastructure.AidopTenantInitService)
+            .GetProperty("S8BusinessRoles", BindingFlags.NonPublic | BindingFlags.Static)!;
+        var roles = ((string Code, string Name, int OrderNo)[])field.GetValue(null)!;
+
+        Assert.Same(S8CanonicalRole.BusinessRoles, roles);
+    }
+
+    // ── 核心回归:非默认租户 ──────────────────────────────────────────────
+
+    /// <summary>
+    /// **本缺陷的核心回归锁**:同一个 Code 在默认租户是 Id=A、在非默认租户是 Id=B。
+    /// 可信作用域过滤后只剩 B,整形必须原样保留 B——绝不能出现 A。
+    /// </summary>
+    [Fact]
+    public void NonDefaultTenant_YieldsTenantLocalRow_NotDefaultTenantRow()
+    {
+        // 可信租户过滤后,仓储只会返回本租户那一行。
+        var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
+
+        var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
+
+        var only = Assert.Single(result);
+        Assert.Equal(UatTenantOrderPlannerId, only.Id);
+        Assert.NotEqual(DefaultTenantOrderPlannerId, only.Id);
+        Assert.Equal("ROLE_ORDER_PLANNER", only.Code);
+    }
+
+    /// <summary>
+    /// 跨租户负测:即便三个租户各有同 Code 的行,可信作用域只会交出当前租户那一行;
+    /// 整形不得把其它租户的 Id 混进来。
+    /// </summary>
+    [Fact]
+    public void CrossTenant_NoLeakage_OnlyCurrentTenantRowSurvives()
+    {
+        var scoped = new[] { (UatTenantOrderPlannerId, "ROLE_ORDER_PLANNER", "订单计划员") };
+
+        var ids = S8NotificationLayerService.ShapeRoleOptions(scoped).Select(x => x.Id).ToList();
+
+        Assert.DoesNotContain(DefaultTenantOrderPlannerId, ids);
+        Assert.DoesNotContain(ThirdTenantOrderPlannerId, ids);
+        Assert.Equal(new[] { UatTenantOrderPlannerId }, ids);
+    }
+
+    /// <summary>
+    /// 筛选依据必须是 Code,不是物理 Id:候选行的 Id 与默认租户 Id 段毫无关系时,
+    /// 依然必须被完整选出(旧实现在这里会返回空)。
+    /// </summary>
+    [Fact]
+    public void Selection_IsByCode_NotByPhysicalIdRange()
+    {
+        var scoped = S8CanonicalRole.BusinessRoles
+            .Select((r, i) => (Id: 900_000_000_000L + i, r.Code, r.Name))
+            .ToArray();
+
+        var result = S8NotificationLayerService.ShapeRoleOptions(scoped);
+
+        Assert.Equal(7, result.Count);
+        Assert.All(result, r => Assert.True(r.Id >= 900_000_000_000L));
+        Assert.All(result, r => Assert.False(
+            r.Id >= 1329908000101L && r.Id <= 1329908000107L, "不得出现默认租户物理 Id"));
+    }
+
+    // ── 排序 / 缺失 / 重复 ────────────────────────────────────────────────
+
+    /// <summary>排序由 canonical OrderNo 决定,不由物理 Id 决定——即便 Id 顺序完全颠倒。</summary>
+    [Fact]
+    public void Ordering_FollowsCanonicalOrder_NotPhysicalId()
+    {
+        var reversed = S8CanonicalRole.BusinessRoles
+            .Select((r, i) => (Id: 999L - i, r.Code, r.Name))   // Id 与 canonical 序相反
+            .ToArray();
+
+        var codes = S8NotificationLayerService.ShapeRoleOptions(reversed).Select(x => x.Code);
+
+        Assert.Equal(S8CanonicalRole.BusinessRoleCodes, codes);
+    }
+
+    /// <summary>
+    /// 缺失行为:canonical 白名单里某个 Code 在当前租户不存在时,
+    /// 只是**不出现这一项**——不回落默认租户、不自动建角色、不抛异常。
+    /// </summary>
+    [Fact]
+    public void MissingCanonicalRole_IsSimplyOmitted_NoFallbackNoThrow()
+    {
+        var partial = new[]
+        {
+            (10L, "ROLE_QC", "质检员"),
+            (11L, "ROLE_PURCHASER", "采购员"),
+        };
+
+        var result = S8NotificationLayerService.ShapeRoleOptions(partial);
+
+        Assert.Equal(new[] { "ROLE_PURCHASER", "ROLE_QC" }, result.Select(x => x.Code)); // 802 < 805
+        Assert.DoesNotContain(result, x => x.Code == "ROLE_ORDER_PLANNER");
+    }
+
+    /// <summary>空作用域(该租户一个 canonical 角色都没有)返回空列表,而不是异常。</summary>
+    [Fact]
+    public void EmptyScope_ReturnsEmpty_NotThrow()
+    {
+        Assert.Empty(S8NotificationLayerService.ShapeRoleOptions(
+            Array.Empty<(long, string, string)>()));
+    }
+
+    /// <summary>
+    /// 重复 Code(DB 无唯一索引,属已登记技术债)必须**确定性**收敛为一项,
+    /// 且取 Id 最小者——保证同一份数据每次给出同一个候选。
+    /// </summary>
+    [Fact]
+    public void DuplicateCode_CollapsesDeterministically_ToLowestId()
+    {
+        var dup = new[]
+        {
+            (77L, "ROLE_QC", "质检员-B"),
+            (55L, "ROLE_QC", "质检员-A"),
+        };
+
+        var only = Assert.Single(S8NotificationLayerService.ShapeRoleOptions(dup));
+        Assert.Equal(55L, only.Id);
+        Assert.Equal("质检员-A", only.Name);
+    }
+
+    /// <summary>整形对同一输入必须幂等且顺序稳定(跑两次结果逐项相同)。</summary>
+    [Fact]
+    public void Shaping_IsStableAcrossInvocations()
+    {
+        var rows = S8CanonicalRole.BusinessRoles
+            .Select((r, i) => (Id: 500L + i, r.Code, r.Name)).ToArray();
+
+        Assert.Equal(
+            S8NotificationLayerService.ShapeRoleOptions(rows),
+            S8NotificationLayerService.ShapeRoleOptions(rows));
+    }
+}

+ 5 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantInitService.cs

@@ -16,16 +16,11 @@ public sealed class AidopTenantInitService : ITransient
     /// <summary>AIDOPDemo:S8 七模块场景模板来源,不含规则、数据源或凭据。</summary>
     /// <summary>AIDOPDemo:S8 七模块场景模板来源,不含规则、数据源或凭据。</summary>
     public const long S8SceneTemplateTenantId = 797403760988230L;
     public const long S8SceneTemplateTenantId = 797403760988230L;
 
 
-    private static readonly (string Code, string Name, int OrderNo)[] S8BusinessRoles =
-    {
-        ("ROLE_ORDER_PLANNER", "订单计划员", 800),
-        ("ROLE_PRODUCTION_PLANNER", "生产计划员", 801),
-        ("ROLE_PURCHASER", "采购员", 802),
-        ("ROLE_WH_INBOUND", "仓库收货员", 803),
-        ("ROLE_WH_OUTBOUND", "仓库发货员", 804),
-        ("ROLE_QC", "质检员", 805),
-        ("ROLE_EQUIP_MAINT", "设备维修员", 806),
-    };
+    // S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:改引用 canonical 单一来源。
+    // 原先此处与 S8SysRoleSeedData 各存一份逐字相同的字面量,靠人工同步;
+    // role-options 若再复制第三份必然分叉,故统一收口到 S8CanonicalRole。内容未变。
+    private static (string Code, string Name, int OrderNo)[] S8BusinessRoles
+        => Admin.NET.Plugin.AiDOP.Infrastructure.S8.S8CanonicalRole.BusinessRoles;
 
 
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
 
 

+ 54 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8CanonicalRole.cs

@@ -0,0 +1,54 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:S8 业务角色的 **canonical 定义(唯一来源)**。
+///
+/// <para><b>身份模型(本批锁定)</b>:
+/// <c>SysRole.Code</c> = **逻辑身份**,跨租户稳定;
+/// <c>SysRole.Id</c> = **租户本地物理身份**,同一个 Code 在每个租户都有各自不同的 Id。
+/// 因此任何「按角色找行」的逻辑都必须**按 Code 在可信租户作用域内查**,
+/// 绝不能按写死的物理 Id 去找——那等于假设所有租户共用默认租户那一份角色行。</para>
+///
+/// <para><b>为什么需要这个文件</b>:这 7 个角色此前在仓内被**逐字复制了两份**
+/// (<c>S8SysRoleSeedData</c> 种默认租户、<c>AidopTenantInitService</c> 按租户复制),
+/// 内容恰好一致纯属人工同步。再在第三处(role-options)复制一份必然分叉,
+/// 所以本文件把两份合并为一份,两个既有定义改为引用它。</para>
+///
+/// <para>⚠️ <b>不含 <c>sys_admin</c></b>:那是平台系统管理员角色(默认租户 `系统管理员`),
+/// 既不在上述任一 S8 角色定义里,也不被任何一条生效通知层引用
+/// (14 条 GLOBAL 层只引用下列 Code 中的 5 个)。仓内唯一出现 <c>sys_admin</c> 的通知层
+/// 属于另一个租户的 legacy demo 行,且其 scene 是已被禁止新建的非规范场景。
+/// 故它**不是** S8 业务角色,不应出现在租户的 CFG_NOTIFY 角色候选里。</para>
+/// </summary>
+public static class S8CanonicalRole
+{
+    /// <summary>
+    /// S8 七个业务角色(异常协同承接)。
+    /// <c>OrderNo</c> 同时是**产品展示顺序**——产品顺序不得由数据库物理 Id 决定,
+    /// 否则同一份配置在不同租户会呈现不同排序。
+    /// </summary>
+    public static readonly (string Code, string Name, int OrderNo)[] BusinessRoles =
+    {
+        ("ROLE_ORDER_PLANNER",      "订单计划员", 800),
+        ("ROLE_PRODUCTION_PLANNER", "生产计划员", 801),
+        ("ROLE_PURCHASER",          "采购员",     802),
+        ("ROLE_WH_INBOUND",         "仓库收货员", 803),
+        ("ROLE_WH_OUTBOUND",        "仓库发货员", 804),
+        ("ROLE_QC",                 "质检员",     805),
+        ("ROLE_EQUIP_MAINT",        "设备维修员", 806),
+    };
+
+    /// <summary>canonical 业务角色 Code 白名单(候选集合的**唯一**筛选依据)。</summary>
+    public static readonly string[] BusinessRoleCodes = Array.ConvertAll(BusinessRoles, x => x.Code);
+
+    /// <summary>
+    /// canonical 展示序。未知 Code 排到末尾而不是抛错——
+    /// 排序不是校验入口,不该因为出现一个陌生 Code 就让整个下拉挂掉。
+    /// </summary>
+    public static int OrderOf(string? code)
+    {
+        foreach (var r in BusinessRoles)
+            if (string.Equals(r.Code, code, StringComparison.Ordinal)) return r.OrderNo;
+        return int.MaxValue;
+    }
+}

+ 5 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8SysRoleSeedData.cs

@@ -13,16 +13,11 @@ public class S8SysRoleSeedData : ISqlSugarEntitySeedData<SysRole>
     {
     {
         var ct = DateTime.Parse("2026-04-18 00:00:00");
         var ct = DateTime.Parse("2026-04-18 00:00:00");
 
 
-        return new[]
-        {
-            R("ROLE_ORDER_PLANNER",      "订单计划员", 800, ct),
-            R("ROLE_PRODUCTION_PLANNER", "生产计划员", 801, ct),
-            R("ROLE_PURCHASER",          "采购员",     802, ct),
-            R("ROLE_WH_INBOUND",         "仓库收货员", 803, ct),
-            R("ROLE_WH_OUTBOUND",        "仓库发货员", 804, ct),
-            R("ROLE_QC",                 "质检员",     805, ct),
-            R("ROLE_EQUIP_MAINT",        "设备维修员", 806, ct),
-        };
+        // S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:改引用 canonical 单一来源(内容未变)。
+        // 原先此处与 AidopTenantInitService 各存一份逐字相同的字面量,靠人工同步。
+        return Array.ConvertAll(
+            Admin.NET.Plugin.AiDOP.Infrastructure.S8.S8CanonicalRole.BusinessRoles,
+            r => R(r.Code, r.Name, r.OrderNo, ct));
     }
     }
 
 
     private static SysRole R(string code, string name, int orderNo, DateTime ct) =>
     private static SysRole R(string code, string name, int orderNo, DateTime ct) =>

+ 51 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs

@@ -150,16 +150,63 @@ public class S8NotificationLayerService : ITransient
             .Distinct(StringComparer.Ordinal));
             .Distinct(StringComparer.Ordinal));
     }
     }
 
 
+    /// <summary>
+    /// S8-CFG-NOTIFY-NONDEFAULT-ROLE-OPTIONS-FIX-1:通知分层的**目标角色候选**。
+    ///
+    /// <para>原实现按**默认租户的物理 Id** 筛选
+    /// (<c>Id BETWEEN 1329908000101 AND 1329908000107</c> 外加 <c>1300000000101</c>)。
+    /// 这些 Id 全部属于默认租户 <c>1300000000001</c>;而 <c>AidopTenantInitService</c>
+    /// 是**按租户各复制一份**这 7 个角色的——同 Code、不同 Id。
+    /// 于是在任何非默认租户下,仓储的租户过滤把这些 Id 全部滤掉,候选列表恒为空,
+    /// 配置页无法查看或编辑目标角色。(UAT 实测:自有 7 个 canonical 角色,候选却返回 0。)</para>
+    ///
+    /// <para>修正为**按 canonical Role Code 白名单在可信租户作用域内查**:
+    /// Code 是逻辑身份、跨租户稳定,Id 是租户本地物理身份。
+    /// 这里**不显式传 tenantId**——租户边界由 <c>SqlSugarRepository&lt;SysRole&gt;</c> 的
+    /// 全局租户过滤按认证身份施加,与 S8 其余读路径同一机制;为这一个方法另开旁路
+    /// 反而会绕过可信作用域。</para>
+    ///
+    /// <para>返回形状保持 <c>{ value, label }</c> 不变(<c>value = Code</c>):
+    /// <c>ado_s8_notification_layer.target_role_ids</c> 存的就是 Code,
+    /// 改成物理 Id 会当场废掉既有配置与 <c>S8RoleResolver</c> 的解析口径。
+    /// 顺带的好处是**出参里根本没有 Id**,也就不存在把默认租户物理 Id 泄漏给其它租户的可能。</para>
+    ///
+    /// <para>缺失行为(有意):canonical 白名单里某个 Code 在当前租户不存在时,
+    /// **只是不出现这一项**——不回落默认租户、不自动建角色、不报错。</para>
+    /// </summary>
     public async Task<List<object>> GetRoleOptionsAsync()
     public async Task<List<object>> GetRoleOptionsAsync()
     {
     {
+        var codes = S8CanonicalRole.BusinessRoleCodes;
+
         var roles = await _roleRep.AsQueryable()
         var roles = await _roleRep.AsQueryable()
-            .Where(x => (x.Id >= 1329908000101L && x.Id <= 1329908000107L) || x.Id == 1300000000101L)
-            .OrderBy(x => x.Id)
-            .Select(x => new { value = x.Code, label = x.Name })
+            .Where(x => codes.Contains(x.Code))
+            .Select(x => new { x.Id, x.Code, x.Name })
             .ToListAsync();
             .ToListAsync();
-        return roles.Cast<object>().ToList();
+
+        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();
     }
     }
 
 
+    /// <summary>
+    /// role-options 的**纯整形逻辑**(去重 + 排序),与 DB 解耦以便直接单测。
+    ///
+    /// <para>去重:同租户同 Code 重复目前实测为 0(无 DB 唯一索引,属已登记技术债)。
+    /// 取 Id 最小者是为了保证**确定性**,不是为了把重复数据藏起来——
+    /// 重复本身仍应由数据治理批次处理。</para>
+    ///
+    /// <para>排序:由 canonical <c>OrderNo</c> 决定,**不由物理 Id 决定**;
+    /// 否则同一份配置在不同租户会呈现不同顺序。</para>
+    /// </summary>
+    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。
     // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
     public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope)
     public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body, S8TrustedScope scope)
     {
     {