소스 검색

feat: UAT 租户占位菜单收口(S0/S5/S6/S7)| server 1.0.424

对 UATTEST_CHL 租户隐藏 12 个仍指向 /aidop/planning/index 的未实现占位菜单,
并连带摘掉 4 个子项全为占位、否则会渲染成可点击死链的父目录。

问题:这批菜单已在 SysMenu 全局登记且授权给全部 10 个租户,UAT 测试账号可以
点进空壳页。更关键的是 AidopMenuLinkSync.EnsureLinked 每次启动会把 Ai-DOP 种子
菜单无差别回填到所有租户的 SysTenantMenu,判据只有「这行还在吗」,主键又是
(tenantId, menuId) 的确定性哈希 —— 任何租户级取消授权(含走页面
SysTenantService.GrantMenu 的正规操作)都会在下次重启被按原主键复活。

做法:新增 ado_tenant_menu_suppression 登记 (TenantId, MenuId) 抑制对;
EnsureLinked 读它、回填时跳过、并主动删除命中的 SysTenantMenu 行,从「覆盖运维
意图」变成「强制执行运维意图」,重启自愈。抑制清单为空时全程 no-op。

边界:
- 只删 SysTenantMenu 中该租户的 16 行;不改 SysMenu 全局定义,不改 SysRoleMenu
  (角色授权保留,撤销抑制并重启即精确还原,该回滚路径已实测)
- 其余 9 个租户授权行数未变
- 无前端改动,无 tenantId 硬编码
- S8 经审计无 planning 占位菜单;S9/S4/M11-M16 本轮不处理

脚本:1.0.424.sql 幂等(重跑影响 0 行);1.0.424.verify.sql 为 8 条环境无关的
真值不变量断言(AutoVersionUpdate 逐条 SqlQuerySingle 要求真值,故不得含 SET
或多列报表,也不得断言本机计数);人工对账与回滚在 UAT-PLACEHOLDER-MENU-HIDE.ops.sql,
故意不带版本号前缀以避免被当作迁移自动执行。
YY968XX 16 시간 전
부모
커밋
be2c7ca28d

+ 12 - 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.423</AssemblyVersion>
-    <FileVersion>1.0.423</FileVersion>
-    <Version>1.0.423</Version>
+    <AssemblyVersion>1.0.424</AssemblyVersion>
+    <FileVersion>1.0.424</FileVersion>
+    <Version>1.0.424</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -583,6 +583,15 @@
     <None Update="UpdateScripts\1.0.420.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.424.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.424.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 140 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.424.sql

@@ -0,0 +1,140 @@
+-- ===========================================================================
+-- UAT 租户占位菜单收口(S0 / S5 / S6 / S7)
+--
+-- 背景:一批菜单已在 SysMenu 全局登记,但 Component 仍指向通用占位页
+--       `/aidop/planning/index`(AidopDemoShell + el-empty「后续在此接入 Ai-DOP
+--       业务页面」),对应前端业务目录与组件均不存在,属「纯菜单占位、零实现」。
+--       UAT 租户(SysOrg.Code = 'UATTEST_CHL')的测试账号能正常点进这些空壳页。
+--
+-- 目标:只让该 UAT 租户看不到这些占位功能。
+--       - 不删除、不禁用、不隐藏任何 SysMenu 全局记录
+--       - 不影响其余 9 个租户
+--       - 不改前端,不硬编码 tenantId
+--
+-- 机制:菜单可见性 = SysMenu(Type≠Btn ∧ Status=Enable)
+--                  ∩ SysTenantMenu[登录租户]
+--                  ∩ SysRoleMenu[用户角色集](含祖先补全)
+--       其中 SysTenantMenu 是唯一的「租户维度」硬白名单
+--       (SysMenuService.GetSugarQueryableAndTenantId → InnerJoinIF<SysTenantMenu>),
+--       对该租户内所有账号类型生效(含 SysAdmin 888),且会同时收窄
+--       「该租户内新建角色时的可选菜单范围」
+--       (SysRoleMenuService.GrantRoleMenu 只落 SysTenantMenu 允许集合的交集)。
+--       因此从 SysTenantMenu 移除,正是「租户级隐藏」的语义。
+--
+-- ⚠️ 为什么还要配一张抑制表:
+--       AidopMenuLinkSync.EnsureLinked(Plugin.AiDOP/Startup.cs:79,每次进程启动
+--       无条件执行)会把 Ai-DOP 种子菜单回填到**全部租户**的 SysTenantMenu,判据
+--       只有「这行还在吗」,且主键是 (tenantId, menuId) 的确定性哈希 —— 单纯 DELETE
+--       会在下一次 restart_aidop.sh 后以同一主键原样复活。ado_tenant_menu_suppression
+--       给回填逻辑一个「这是有意取消的」显式信号,并由启动逻辑强制执行,重启自愈。
+--
+-- 范围:12 个占位叶子(S0×1 + S5×4 + S6×5 + S7×2)
+--       + 4 个「子项全为占位」的父目录 = 16 对 (TenantId, MenuId)。
+--       S0 的父节点「S0 运营建模」下另有「数据建模」真实子树(58 个已实现页面),
+--       故父节点**不摘**。
+--       S8 经审计**无**任何 planning 占位菜单,本脚本不涉及 S8。
+--       S9 / S4 / M11-M16 的占位菜单**本轮明确不处理**。
+--
+-- 幂等:CREATE TABLE IF NOT EXISTS / INSERT ... WHERE NOT EXISTS / DELETE,
+--       可重复执行,重跑影响行数为 0。
+-- 校验:1.0.424.verify.sql(AutoVersionUpdate 自动逐条真值断言)
+-- 对账/回滚:UAT-PLACEHOLDER-MENU-HIDE.ops.sql(人工执行,不会被自动运行)
+-- ===========================================================================
+
+-- 租户由稳定业务键解析,不写死数字 Id(与 1.0.420.sql 同口径)
+SET @t_uat := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+               WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+
+
+-- ---------------------------------------------------------------------------
+-- 00 · 抑制清单表
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `ado_tenant_menu_suppression` (
+  `Id`        bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `TenantId`  bigint       NOT NULL                COMMENT '租户 Id(SysTenant.Id)',
+  `MenuId`    bigint       NOT NULL                COMMENT '菜单 Id(SysMenu.Id)',
+  `Reason`    varchar(256)     NULL                COMMENT '抑制原因(为何该租户不应看到该菜单)',
+  `CreatedAt` datetime     NOT NULL                COMMENT '创建时间',
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_tenant_menu_suppression` (`TenantId`, `MenuId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户级菜单抑制清单';
+
+
+-- ---------------------------------------------------------------------------
+-- 01 · 占位叶子(15 条)
+--      双重业务键:Path 命中 + Component 必须仍是占位页。
+--      若某条日后被换成真实组件,这里自然不再命中,不会误抑制真实功能。
+-- ---------------------------------------------------------------------------
+INSERT INTO ado_tenant_menu_suppression (TenantId, MenuId, Reason, CreatedAt)
+SELECT @t_uat, m.Id, CONCAT('UAT 收口:未实现占位页 ', m.Path), NOW()
+FROM SysMenu m
+WHERE @t_uat IS NOT NULL
+  AND m.Component = '/aidop/planning/index'
+  AND m.Path IN (
+      -- S0 业务建模(FUNC-S0-065)
+      -- 该条 SysMenu.Status 已为 2/停用、当前本就不渲染;纳入抑制是为了口径一致 +
+      -- 防御「日后有人把 Status 改回 1 时它在 UAT 租户重新冒出来」。
+      '/aidop/s0/002',
+      -- S5 物料仓储看板(FUNC-S5-014 ~ 017)
+      '/aidop/s5/warehouse-kanban/receiving-task',
+      '/aidop/s5/warehouse-kanban/issuing-task',
+      '/aidop/s5/warehouse-kanban/turnover-report',
+      '/aidop/s5/warehouse-kanban/age-analysis',
+      -- S6 生产记录管理(FUNC-S6-001)
+      '/aidop/s6/production-record/order-list',
+      -- S6 生产执行看板(FUNC-S6-005 ~ 008)
+      '/aidop/s6/execution-kanban/work-order',
+      '/aidop/s6/execution-kanban/workshop-efficiency',
+      '/aidop/s6/execution-kanban/equipment-oee',
+      '/aidop/s6/execution-kanban/labor-hours',
+      -- S7 成品仓储看板(FUNC-S7-005 / 006)
+      '/aidop/s7/finished-warehouse-kanban/shipment-task',
+      '/aidop/s7/finished-warehouse-kanban/sluggish'
+  )
+  AND NOT EXISTS (SELECT 1 FROM ado_tenant_menu_suppression s
+                  WHERE s.TenantId = @t_uat AND s.MenuId = m.Id);
+
+
+-- ---------------------------------------------------------------------------
+-- 02 · 纯占位父目录(5 条)
+--      菜单树不会自动裁掉空父节点:后端 ToTree 无 children.Any() 判断,
+--      ExpandMenuIdsWithAncestorsAsync 只正向补祖先;前端 vertical.vue 对
+--      children.length===0 的目录走 v-else 渲染成**可点击的死链 el-menu-item**。
+--      所以父目录必须显式一并移除。
+--
+--      安全断言:只在「该目录下所有非按钮子菜单都是占位」时才登记。
+--      任何一个真实子菜单存在 → 整条不命中 → 绝不会摘掉还有真实功能的目录。
+-- ---------------------------------------------------------------------------
+INSERT INTO ado_tenant_menu_suppression (TenantId, MenuId, Reason, CreatedAt)
+SELECT @t_uat, m.Id, CONCAT('UAT 收口:子项全为占位,避免留下空壳目录 ', m.Path), NOW()
+FROM SysMenu m
+WHERE @t_uat IS NOT NULL
+  AND m.Path IN (
+      '/aidop/s5/warehouse-kanban',          -- 物料仓储看板
+      '/aidop/s6/production-record',         -- 生产记录管理
+      '/aidop/s6/execution-kanban',          -- 生产执行看板
+      '/aidop/s7/finished-warehouse-kanban'  -- 成品仓储看板
+  )
+  -- 必须确有子菜单(防止把一个孤立节点当目录摘掉)
+  AND EXISTS (SELECT 1 FROM SysMenu c WHERE c.Pid = m.Id AND c.Type <> 3)
+  -- 且非按钮子菜单必须 100% 是占位页
+  AND NOT EXISTS (
+      SELECT 1 FROM SysMenu c
+      WHERE c.Pid = m.Id
+        AND c.Type <> 3
+        AND (c.Component IS NULL OR c.Component <> '/aidop/planning/index')
+  )
+  AND NOT EXISTS (SELECT 1 FROM ado_tenant_menu_suppression s
+                  WHERE s.TenantId = @t_uat AND s.MenuId = m.Id);
+
+
+-- ---------------------------------------------------------------------------
+-- 03 · 按抑制清单移除该租户的菜单授权
+--      只删 SysTenantMenu,且只删 @t_uat 这一个租户的行。
+--      **不动 SysRoleMenu** —— 角色授权保留,撤销抑制后可精确还原原可见性。
+--      **不动 SysMenu** —— 全局菜单定义零改动。
+-- ---------------------------------------------------------------------------
+DELETE tm FROM SysTenantMenu tm
+JOIN ado_tenant_menu_suppression s
+  ON s.TenantId = tm.TenantId AND s.MenuId = tm.MenuId
+WHERE tm.TenantId = @t_uat;

+ 74 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.424.verify.sql

@@ -0,0 +1,74 @@
+-- ===========================================================================
+-- UAT 租户占位菜单收口 VERIFY —— 全部 SELECT 真值标量断言
+--
+-- 约定(AutoVersionUpdate.RunVerifyScriptIfExists,AutoVersionUpdate.cs:225-248):
+--   逐条 SqlQuerySingle<object> 取「首行首列」,非真值即抛异常 → 迁移 Failed
+--   → StopApplicationOnFailure 默认 true → 应用启动中止。
+--   因此本文件:
+--     ① 不得出现 SET / 非 SELECT 语句(返回 null = 判失败)
+--     ② 每条必须是布尔标量,不能是多列报表(首列标签恒真 = 断言失效)
+--     ③ 不得断言本机计数(16 / 25 / 22 之类),否则在没有 UATTEST_CHL 的
+--        环境上必然误判。以下全部写成**环境无关的不变量**:该租户不存在时
+--        抑制清单为空,各断言自然成立。
+--
+-- 人工对账报表与回滚见同目录 UAT-PLACEHOLDER-MENU-HIDE.ops.sql
+-- (无版本号前缀 → Version.TryParse 失败 → AutoVersionUpdate 永不自动执行)。
+-- ===========================================================================
+
+-- ---------- A. 核心不变量:抑制清单中的每一对,在 SysTenantMenu 中都不存在 ----------
+SELECT COUNT(*) = 0 FROM SysTenantMenu tm
+  JOIN ado_tenant_menu_suppression s ON s.TenantId = tm.TenantId AND s.MenuId = tm.MenuId;
+
+-- ---------- B. 租户隔离:抑制清单只允许出现 UATTEST_CHL 一个租户 ----------
+SELECT COUNT(*) = 0 FROM ado_tenant_menu_suppression s
+  WHERE s.TenantId <> IFNULL(
+      (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+        WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1), -1);
+
+-- ---------- C. 全局 SysMenu 零改动:被抑制的菜单必须仍存在于 SysMenu ----------
+SELECT COUNT(*) = 0 FROM ado_tenant_menu_suppression s
+  LEFT JOIN SysMenu m ON m.Id = s.MenuId
+  WHERE m.Id IS NULL;
+
+-- ---------- D. 绝不误伤真实功能:抑制对象只能是占位页或纯目录 ----------
+SELECT COUNT(*) = 0 FROM ado_tenant_menu_suppression s
+  JOIN SysMenu m ON m.Id = s.MenuId
+  WHERE m.Component <> '/aidop/planning/index' AND m.Component <> 'Layout';
+
+-- ---------- E. 被抑制的目录必须确实「非按钮子项 100% 是占位」----------
+SELECT COUNT(*) = 0 FROM ado_tenant_menu_suppression s
+  JOIN SysMenu m ON m.Id = s.MenuId
+  WHERE m.Component = 'Layout'
+    AND EXISTS (SELECT 1 FROM SysMenu c
+                 WHERE c.Pid = m.Id AND c.Type <> 3
+                   AND (c.Component IS NULL OR c.Component <> '/aidop/planning/index'));
+
+-- ---------- F. 效果断言:S0/S5/S6/S7 占位菜单对该租户不可见 ----------
+--            S9 / S4 / M11-M16 本轮不处理,不纳入断言。
+SELECT COUNT(*) = 0 FROM SysMenu m
+  JOIN SysTenantMenu tm ON tm.MenuId = m.Id
+   AND tm.TenantId = IFNULL(
+       (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+         WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1), -1)
+  WHERE m.Component = '/aidop/planning/index'
+    AND (m.Path LIKE '/aidop/s0/%' OR m.Path LIKE '/aidop/s5/%'
+         OR m.Path LIKE '/aidop/s6/%' OR m.Path LIKE '/aidop/s7/%');
+
+-- ---------- G. 不留空壳父目录:该租户可见的 /aidop 目录必须仍有可见子菜单 ----------
+SELECT COUNT(*) = 0 FROM SysMenu p
+  JOIN SysTenantMenu tm ON tm.MenuId = p.Id
+   AND tm.TenantId = IFNULL(
+       (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+         WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1), -1)
+  WHERE p.Status = 1 AND p.Type = 1 AND p.Path LIKE '/aidop/%'
+    AND NOT EXISTS (
+        SELECT 1 FROM SysMenu c
+          JOIN SysTenantMenu ctm ON ctm.MenuId = c.Id AND ctm.TenantId = tm.TenantId
+         WHERE c.Pid = p.Id AND c.Status = 1 AND c.Type <> 3);
+
+-- ---------- H. 跨租户回归:这些菜单在其他租户仍有授权(证明未跨租户误删)----------
+--            抑制清单为空时自然成立。
+SELECT (SELECT COUNT(*) FROM ado_tenant_menu_suppression) = 0
+    OR (SELECT COUNT(*) FROM SysTenantMenu tm
+         WHERE tm.MenuId IN (SELECT MenuId FROM ado_tenant_menu_suppression)
+           AND tm.TenantId NOT IN (SELECT TenantId FROM ado_tenant_menu_suppression)) > 0;

+ 69 - 0
server/Admin.NET.Web.Entry/UpdateScripts/UAT-PLACEHOLDER-MENU-HIDE.ops.sql

@@ -0,0 +1,69 @@
+-- ===========================================================================
+-- UAT 租户占位菜单收口 —— 人工运维脚本(对账报表 + 回滚)
+--
+-- ⚠️ 本文件**不会**被 AutoVersionUpdate 自动执行:
+--    LoadMigrationScripts(AutoVersionUpdate.cs:391-420)先排除 *.verify.sql,
+--    再对 Path.GetFileNameWithoutExtension 做 Version.TryParse;
+--    "UAT-PLACEHOLDER-MENU-HIDE.ops" 解析失败 → 该文件被过滤掉。
+--    **不要给本文件加 1.0.<n> 版本号前缀**,否则会变成自动执行的迁移脚本。
+--
+--    自动执行的断言在 1.0.424.verify.sql(真值标量,环境无关)。
+--    本文件供人工按需执行,两段互相独立,**不要整文件一次跑完**。
+-- ===========================================================================
+
+
+-- ###########################################################################
+-- 第一段 · 对账报表(只读,可随时执行)
+-- ###########################################################################
+
+SET @t_uat := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+               WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+
+-- R0 租户解析
+SELECT 'R0 租户解析' AS Chk, @t_uat AS TenantId,
+       (SELECT o.Code FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId WHERE t.Id = @t_uat) AS OrgCode;
+
+-- R1 抑制清单明细
+SELECT 'R1 抑制清单' AS Chk, s.MenuId, m.Path, m.Component, m.Status, s.Reason
+FROM ado_tenant_menu_suppression s JOIN SysMenu m ON m.Id = s.MenuId
+WHERE s.TenantId = @t_uat ORDER BY m.Path;
+
+-- R2 该租户 SysTenantMenu 总行数(收口前 493 → 收口后 477)
+SELECT 'R2 该租户租户菜单总行数' AS Chk, COUNT(*) AS Rows_
+FROM SysTenantMenu WHERE TenantId = @t_uat;
+
+-- R3 该租户当前仍可见的 planning 占位菜单(本轮范围内应为空;S9/S4/M11-M16 不在范围)
+SELECT 'R3 仍可见的占位菜单' AS Chk, m.Path, m.Component
+FROM SysMenu m
+JOIN SysTenantMenu tm ON tm.MenuId = m.Id AND tm.TenantId = @t_uat
+WHERE m.Status = 1 AND m.Type <> 3 AND m.Component = '/aidop/planning/index'
+ORDER BY m.Path;
+
+-- R4 跨租户对账:其余租户对这些菜单的授权行数(应各自保持原值不变)
+SELECT 'R4 跨租户授权' AS Chk, t.Id AS TenantId, o.Code AS OrgCode,
+       (SELECT COUNT(*) FROM SysTenantMenu tm
+         WHERE tm.TenantId = t.Id
+           AND tm.MenuId IN (SELECT MenuId FROM ado_tenant_menu_suppression WHERE TenantId = @t_uat)) AS GrantedRows
+FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+WHERE t.Id <> @t_uat ORDER BY t.Id;
+
+-- R5 SysRoleMenu 保留情况(本脚本从不改动它;用于确认回滚可精确还原)
+SELECT 'R5 角色授权保留' AS Chk, COUNT(*) AS Rows_
+FROM SysRoleMenu rm JOIN SysRole r ON r.Id = rm.RoleId
+WHERE r.TenantId = @t_uat
+  AND rm.MenuId IN (SELECT MenuId FROM ado_tenant_menu_suppression WHERE TenantId = @t_uat);
+
+
+-- ###########################################################################
+-- 第二段 · 回滚(会改数据,确认后再单独执行)
+--
+-- 步骤:执行下方 DELETE → 重启后端(restart_aidop.sh)。
+--       AidopMenuLinkSync.EnsureLinked 会按原主键
+--       CommonUtil.GetFixedHashCode(""+tid+menuId, 1300000000000)
+--       把 SysTenantMenu 授权行原样补回;SysRoleMenu 全程未被改动,
+--       因此可见性精确还原到收口之前。该路径已实测验证。
+-- ###########################################################################
+
+-- DELETE FROM ado_tenant_menu_suppression
+--  WHERE TenantId = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+--                     WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);

+ 34 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoTenantMenuSuppression.cs

@@ -0,0 +1,34 @@
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// 租户级菜单抑制清单:登记「某菜单全局存在、但指定租户不应看到」的 (TenantId, MenuId) 对。
+///
+/// 存在原因:<c>AidopMenuLinkSync.EnsureLinked</c> 每次启动会把 Ai-DOP 种子菜单回填到全部租户的
+/// <c>SysTenantMenu</c>,判据仅为「这行还在吗」。因此直接删除 <c>SysTenantMenu</c> 行(无论手工 SQL
+/// 还是走 <c>SysTenantService.GrantMenu</c> 页面取消授权)都活不过一次重启。本表给回填逻辑一个
+/// 「这是有意取消的,别再补回来」的显式信号,并由启动逻辑强制执行,使抑制在重启后自愈。
+///
+/// 边界:只影响本表登记的租户;不改 <c>SysMenu</c> 全局定义,不改 <c>SysRoleMenu</c>。
+/// 撤销抑制 = 删除本表对应行并重启,回填逻辑会按原主键把授权补回。
+/// </summary>
+[SugarTable("ado_tenant_menu_suppression", "租户级菜单抑制清单")]
+[SugarIndex("uk_tenant_menu_suppression", nameof(TenantId), OrderByType.Asc, nameof(MenuId), OrderByType.Asc, true)]
+public class AdoTenantMenuSuppression
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 Id(SysTenant.Id)", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "菜单 Id(SysMenu.Id)", ColumnDataType = "bigint")]
+    public long MenuId { get; set; }
+
+    [SugarColumn(ColumnDescription = "抑制原因(为何该租户不应看到该菜单)", Length = 256, IsNullable = true)]
+    public string? Reason { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; }
+}

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

@@ -1,4 +1,5 @@
 using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
 using Microsoft.Extensions.DependencyInjection;
 using SqlSugar;
 using System.Diagnostics;
@@ -57,6 +58,10 @@ public static class AidopMenuLinkSync
         if (tenantIds.Count == 0)
             return;
 
+        // 租户级抑制清单:登记「某菜单全局存在、但该租户不应看到」的 (TenantId, MenuId) 对。
+        // 不读它就会把运维刻意取消的租户授权原样补回(主键是 (tid, menuId) 的确定性哈希,连行都一模一样)。
+        var suppressedPairs = LoadTenantMenuSuppressions(db);
+
         var tenantMenuPairs = db.Queryable<SysTenantMenu>()
             .Where(tm => tenantIds.Contains(tm.TenantId) && seedMenuIds.Contains(tm.MenuId))
             .Select(tm => new { tm.TenantId, tm.MenuId })
@@ -73,6 +78,8 @@ public static class AidopMenuLinkSync
                     continue;
                 if (tenantMenuPairs.Contains((tid, menu.Id)))
                     continue;
+                if (suppressedPairs.Contains((tid, menu.Id)))
+                    continue;
                 tenantRows.Add(new SysTenantMenu
                 {
                     Id = CommonUtil.GetFixedHashCode("" + tid + menu.Id, 1300000000000),
@@ -85,6 +92,9 @@ public static class AidopMenuLinkSync
         if (tenantRows.Count > 0)
             db.Insertable(tenantRows).ExecuteCommand();
 
+        // 抑制清单登记之前就已存在的授权行,在这里清掉:让本方法从「覆盖运维意图」变成「强制执行运维意图」,重启即自愈。
+        EnforceTenantMenuSuppressions(db, suppressedPairs);
+
         // 已为任一 Ai-DOP 种子菜单授权的角色,补全新增子菜单;并始终包含默认租户系统管理员角色。
         var roleIdsWithAnyAidop = db.Queryable<SysRoleMenu>()
             .Where(rm => seedMenuIds.Contains(rm.MenuId))
@@ -129,6 +139,50 @@ public static class AidopMenuLinkSync
             db.Insertable(roleRows).ExecuteCommand();
     }
 
+    /// <summary>
+    /// 读取租户级菜单抑制清单 <c>ado_tenant_menu_suppression</c>。
+    /// 表可能尚未创建(全新库、版本脚本尚未执行)——此时按「无抑制」处理,不阻断菜单同步。
+    /// </summary>
+    private static HashSet<(long TenantId, long MenuId)> LoadTenantMenuSuppressions(ISqlSugarClient db)
+    {
+        try
+        {
+            if (!db.DbMaintenance.IsAnyTable("ado_tenant_menu_suppression", false))
+                return new HashSet<(long, long)>();
+
+            return db.Queryable<AdoTenantMenuSuppression>()
+                .Select(x => new { x.TenantId, x.MenuId })
+                .ToList()
+                .Select(x => (x.TenantId, x.MenuId))
+                .ToHashSet();
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning("Ai-DOP AidopMenuLinkSync 读取租户菜单抑制清单失败,本次按无抑制处理:" + ex);
+            return new HashSet<(long, long)>();
+        }
+    }
+
+    /// <summary>
+    /// 按抑制清单删除对应租户的 <c>sys_tenant_menu</c> 授权行。
+    /// 只删清单里逐对登记的 (TenantId, MenuId),不动 <c>SysMenu</c> 全局定义、不动 <c>SysRoleMenu</c>
+    /// (保留角色授权,撤销抑制后可精确还原原可见性)。
+    /// </summary>
+    private static void EnforceTenantMenuSuppressions(ISqlSugarClient db, HashSet<(long TenantId, long MenuId)> suppressedPairs)
+    {
+        if (suppressedPairs.Count == 0)
+            return;
+
+        foreach (var group in suppressedPairs.GroupBy(p => p.TenantId))
+        {
+            var tenantId = group.Key;
+            var menuIds = group.Select(p => p.MenuId).Distinct().ToList();
+            db.Deleteable<SysTenantMenu>()
+                .Where(tm => tm.TenantId == tenantId && menuIds.Contains(tm.MenuId))
+                .ExecuteCommand();
+        }
+    }
+
     /// <summary>
     /// S4「采购执行」:数据库关闭种子时菜单不入库,侧栏只能看到历史遗留项(常见仅“供应商交货管理”)。
     /// 从 <see cref="SysMenuSeedData.HasData"/> 取完整行按父链补插,确保交货/退货/看板目录与子菜单齐全。

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -97,7 +97,8 @@ public class Startup : AppStartup
                 typeof(AdoMdpHotWatch),
                 typeof(Admin.NET.Plugin.AiDOP.Entity.SmartOps.AdoS1DashboardRebuildJob),
                 typeof(Admin.NET.Plugin.AiDOP.Entity.SmartOps.AdoModuleDashboardRebuildJob),
-                typeof(Admin.NET.Plugin.AiDOP.Entity.SmartOps.AdoMdpRebuildLock));
+                typeof(Admin.NET.Plugin.AiDOP.Entity.SmartOps.AdoMdpRebuildLock),
+                typeof(AdoTenantMenuSuppression));
 
             AidopTenantMigration.MigrateOldTenantId(dbS4);
             AidopKpiMasterSeed.EnsureSeed(dbS4);