菜单归属与保护
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页解决什么问题:应用声明的菜单落进 sys_menu 之后就是普通行 ——
「系统管理 → 菜单管理」里谁都能改 path / component / perms,也能直接删掉。
本页讲清三件都能自己核对的事:
- 平台怎么认出一行菜单属于哪个应用(四张表反查,不需要给
sys_menu加列); - 哪些操作会被拒(只有三类操作,其中两类被拒,其余一律放行);
- 为什么归属必须连后代一起算(按钮那层不在映射表里)。
菜单清单本身怎么写不在这里,见 菜单与权限 与 应用清单 app.json。
归属从哪来:app_version_menu 的四表反查
菜单同步时已经把 (version_id, menu_id, menu_key) 写进了 app_version_menu,
它就是权威记录。反查链是四跳:
app_install(本租户装了哪些应用,这张表带 tenant_id)
→ app_info(app_code / app_name)
→ app_version(这些应用的**所有**版本)
→ app_version_menu(version_id → menu_id)app_version_menu 的列与约束(DDL 在 wujies-platform/script/sql/update/app_market.sql):
| 列 | 含义 |
|---|---|
id | 主键(雪花,MyBatis-Plus ASSIGN_ID) |
version_id | 指向 app_version.version_id |
menu_id | 指向 sys_menu.menu_id |
menu_key | 清单里 menus[].key 的落库名(幂等 upsert 的依据) |
唯一键 uk_version_menu (version_id, menu_id)、uk_version_menu_key (version_id, menu_key),
另有 idx_version_menu_menu (menu_id) —— 归属反查走的就是这条索引。
接线一共四处:
| 角色 | 位置 | 说明 |
|---|---|---|
| 契约 | wujies-platform/wujies-common/wujies-common-core/src/main/java/cn/wujies/common/core/menu/MenuOwnershipProvider.java | 宿主通用模块不能反向依赖 app-market;MenuOwner(appCode, appName) |
| 实现 | wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/service/AppMenuOwnershipProvider.java | 四表反查 + 后代展开,只排除 del_flag='1' 的记录 |
| 拦截 | wujies-platform/wujies-modules/wujies-system/src/main/java/cn/wujies/system/service/impl/SysMenuServiceImpl.java 的 updateMenu / deleteMenuById | ObjectProvider<MenuOwnershipProvider> 注入,缺席即"没有受保护菜单" |
| 标注 | GET /app/console/owned-menus(权限 system:menu:list)+ 宿主前端 plus-ui/src/views/system/menu/index.vue | 菜单列表里打应用名标签、禁用删除按钮与路由字段 |
要动框架的实体 / VO / Mapper XML / 菜单管理页面(sys_menu 是平台表),
而且会和 app_version_menu 形成两份真相;更关键的是归属本质是「版本 → 菜单」
而不是「菜单 → 应用」:同一应用的不同版本可以有不同菜单树。
三点代价的完整推导见 数据库表 第 12 节。
租户边界:带 tenant_id 的只有第一跳
AppMenuOwnershipProvider 的类注释写「租户过滤交给 MyBatis-Plus 插件(不 ignore)」——
这句话是对的,但看代码时要接着看清一件事:四张表里只有 app_install 带 tenant_id,
app_info / app_version / app_version_menu 都在 tenant.excludes 里(平台级全局表,
见 wujies-platform/wujies-admin/src/main/resources/application.yml 与
配置项)。
所以实际效果是:A 租户装的应用,不会让 B 租户的同名菜单被保护,
差别只产生在 app_install 那一跳;目录与映射本身是全部署共用一份的。
另一个容易误判的点:归属查询只排除 del_flag='1',不按安装状态过滤。
所以停用(disabled)的应用,它的菜单仍受保护 —— 它随时可能被重新启用;
「卸载(保留数据)」同理,安装记录还在,菜单行也还在,归属自然还在。
保护策略:只有三类操作
| 操作 | 允许? | 理由 |
|---|---|---|
改 path / component / perms / menuType / parentId | ❌ 拒绝 | 与插件代码、前端路由、权限标识绑死,改一个字段页面就打不开或权限失效 |
改菜单名 / 图标 / 排序 / 显隐(menuName / icon / orderNum / visible / status) | ✅ 放行 | 管理员按自己的习惯调,没有风险 |
| 删除 | ❌ 拒绝 | 删了只能靠重装 / 升级该应用恢复;想隐藏请改成「停用」 |
判据在 SysMenuServiceImpl.assertAppMenuEditable:它逐字段比对「库里的当前行」与「本次提交的 BO」,
只有真的变了才计入。BO 里传 null 表示"本次不修改该字段"(与 MyBatis-Plus updateById 忽略
null 一致),不会被当成"要改成 null"—— 否则只改菜单名的请求也会被误拦。
被拒时抛的是 ServiceException,原文如下(可直接拿来对日志):
菜单「<菜单名>」由应用「<应用名>」安装,不能修改<路由地址、组件路径、权限标识、菜单类型、上级菜单里变了的那些>。
这些字段与插件代码和前端路由绑定,改了会导致页面打不开或权限失效。
如需调整,请修改该应用的 app.json 后重新打包升级;只改名称、图标、排序、显隐是可以的。
菜单 <menuId>(<应用名>) 由应用安装,不能在菜单管理里删除 ——
删掉之后只能靠重装或升级该应用恢复。如果只是想让它不显示,请改为「停用」或停用该应用。归属要连后代一起算(AppMenuTreeExpander)
映射表里只有清单声明过的菜单,而 sys_menu 里还有一层:
按钮(menu_type='F',例如 gallery:image:query)—— 它们历史上来自平台初始化 SQL,
从来不在映射表里。只看映射表会同时坏两件事,而且都是静默的:
| 场景 | 只看映射表的后果 |
|---|---|
| 菜单保护 | 按钮不受保护,管理员能随手删掉应用按钮,恢复只能靠重装 |
| 彻底删除应用 | 按钮成孤儿(父菜单没了、自己还挂在 parent_id 上),菜单管理页里一堆没有父级的按钮 |
所以归属计算的第 5 步是下钻:AppMenuOwnershipProvider.ownedMenus() 调
AppMenuTreeExpander(同一个类也被 AppPurgeService 用来决定"该删哪些菜单行",
避免两边各写一份然后分叉)。
withDescendants(roots):返回「roots 自己 + 全部层级后代」;descendantsOf(roots):只要后代;- 下钻是广度优先 +
LinkedHashSet去重,数据被手工改坏成环(A 的父是 B、B 的父是 A) 也不会死循环; loadChildrenByParent()一次读全表sys_menu(只取menu_id/parent_id)建索引, 并在TenantHelper.ignore(...)里跑,避免调用方上下文(定时任务等)干扰。
回归测试:wujies-platform/wujies-modules/wujies-app-market/src/test/java/cn/wujies/appmarket/service/AppMenuTreeExpanderTest.java
(5 项,纯函数、不启 Spring、不连库:多层下钻 / 多根不串门 / 叶子 / 空输入 / 环)。
type: "F" 不是可选项:漏写会让全新部署装完没有按钮(前端 v-hasPermi 过不了),
彻底删除也删不掉它们。图床(gallery)的 23 个按钮就是这么补进
wujies/wujies-plugins/wujies-gallery/app.json 的(清单里现在是 1 个 M + 6 个 C + 23 个 F)。
对已有部署还要配一条 seed SQL,把新 menu_key 映射到既有的按钮 ID,
否则下次升级会按"新建"处理、插出第二套按钮 ——
模板见 wujies-platform/script/sql/update/fix_gallery_menu_tree.sql
(一段做三件事:按原 menu_id 插回菜单行、登记 app_version_menu、补角色授权,全幂等)。
在 wujies-gallery 补清单之后,新版本的按钮已经在映射表里了;
但存量部署、以及 im / pay 这类由平台初始化 SQL 建菜单的应用,库里仍有映射表覆盖不到的按钮行。
下钻兜底就是为这一层准备的,别因为它"现在看起来用不上"而删掉。
两个容易搞错的点
1. 保护必须在服务端
菜单管理页(plus-ui/src/views/system/menu/index.vue)会禁用删除按钮、
编辑弹窗里也会禁用路由字段并给一条 el-alert 提示,但接口是同权限可调的
(其它客户端、直接 curl)。页面禁用只是"提前说清楚能改什么",不是安全边界;
真正的收口点是 SysMenuServiceImpl。
2. 框架自带的检查会先拦住大多数删除
SysMenuController 在进入 service 之前就有两道判断(原文):
| 位置 | 判断 | 返回 |
|---|---|---|
DELETE /system/menu/{menuId} | hasChildByMenuId(menuId) | R.warn("存在子菜单,不允许删除") |
| 同上 | checkMenuExistRole(menuId) | R.warn("菜单已分配,不允许删除") |
DELETE /system/menu/cascade/{menuIds} | 只有 hasChildByMenuId 这一道,之后直接进 service | —— |
所以实测归属保护时必须先摘掉角色分配、先删掉子菜单,否则根本走不到归属检查 —— 归属检查是最后一道,专治"菜单成了孤儿"(没有子菜单、也没有角色分配,但仍然是应用装的)。
MenuOwnershipProvider.ownerOf(menuId) 是契约里的 default 方法,实现每次都重新跑一遍
ownedMenus()(四表查询 + 一次 sys_menu 全表读)。assertAppMenuDeletable 是按 menuId 循环
调它的,所以批量删除的开销随菜单条数线性放大。要优化就改这里,别去改页面。
「安装成功、状态 enabled,但一个菜单都没有」
这是实测踩到过的一类静默失败,根因是死映射:某条归属映射挂在软删除的旧版本上
(version_id 已不在目录里),「彻底删除」清映射时按"本次删掉的菜单"(menu_id)和
"清单声明的键"(menu_key)两条线索都找不到它。
而菜单同步是按 appId 跨版本解析 key → menu_id 的,于是下次安装 / 升级会复用它指向的
已删除菜单 ID:updateById 影响 0 行、映射照样写回。
两处修法(都在代码里,缺一不可):
| # | 位置 | 做什么 |
|---|---|---|
| 1 | wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/service/AppPurgeService.java | 清归属映射按三条线索并行:menu_id ∈ 本次删掉的菜单 ∪ menu_key ∈ 清单声明的键 ∪ version_id ∈ 该应用已知版本 |
| 2 | .../service/impl/AppMenuSyncServiceImpl.java 的 dropMissingMenuRows | 自愈:同步前把"映射指向已不存在的菜单行"的项丢掉、按新菜单重建,并 WARN 出被丢的键 |
自愈那一步的日志文案(用来确认"上游映射没清干净"):
应用[<appId>]有 N 条菜单归属指向已不存在的菜单行,已按新菜单重建(原映射没清干净): [<key>→<menuId>, ...]映射表为空时,AppMenuPlanner 会把清单里每个菜单都当新建,直接插出第二套菜单树。
AppMenuSyncServiceImpl 为此先按结构认领既有菜单(claimExistingMenus:从
(parent_id=0, 清单根 path) 起步,逐层用 path(M/C)/ perms(F)匹配、一行只认领一次,
刻意不看 remark)。端点是 POST /app/install/{installId}/sync-menus(权限 app:install:edit),
误建的行用 wujies-platform/script/sql/update/cleanup_duplicate_app_menus.sql 清理(幂等)。
卸载 / 彻底删除时,归属同时是删除依据
菜单行是全局行(sys_menu 在 tenant.excludes 里,不分租户),所以删除比保护严格得多,
AppPurgeService.purge(...) 先过两道闸:
- 别的租户还在用就不动全局的东西:还有别的
app_install记录(del_flag='0'且状态非uninstalled,且排除自己那一条)时,菜单 / 表 / 迁移记录 / 产物全部跳过, 只把原因写进任务日志; - 平台表前缀受保护:只删迁移脚本声明过的表,命中
sys_/app_等前缀一律跳过并告警。
菜单这一侧的对应关系:
| 动作 | 菜单与归属 |
|---|---|
| 卸载(保留数据,默认) | 菜单行与归属保留;本租户授权被回收;菜单靠 AppMenuFilterImpl("归属本应用但本租户未启用")隐藏 |
彻底删除(purge=true) | withDescendants(mappedMenuIds) 连后代一起删 + 回收所有角色授权 + 按三条线索清映射 |
菜单删除、数据表删除的结果都会写进任务日志,直接在 应用任务中心 看,不用翻服务端日志。
怎么自己核对
看某一行菜单归谁
GET /app/console/owned-menus # 权限 system:menu:list
→ [{menuId, appCode, appName}, ...] 按 menuId 升序返回的是 AppMenuOwnershipProvider.ownedMenus() 的全部结果,已经含后代 ——
所以它的条数通常比下面这条 SQL 多,多出来的就是"清单没声明、被连坐保护"的按钮。
用 SQL 复刻反查(不含后代下钻)
SELECT avm.menu_id, avm.menu_key, ai.app_code, ai.app_name
FROM app_version_menu avm
JOIN app_version av ON av.version_id = avm.version_id AND av.del_flag = '0'
JOIN app_info ai ON ai.app_id = av.app_id AND ai.del_flag = '0'
JOIN app_install i ON i.app_id = ai.app_id AND i.del_flag = '0'
WHERE i.tenant_id = '000000' -- 换成你在查的租户
ORDER BY avm.menu_id;查单个菜单是不是映射登记过的:
SELECT ai.app_code, ai.app_name, avm.menu_key
FROM app_version_menu avm
JOIN app_version av ON av.version_id = avm.version_id AND av.del_flag = '0'
JOIN app_info ai ON ai.app_id = av.app_id AND ai.del_flag = '0'
WHERE avm.menu_id = <menuId>;查不到不等于不受保护 —— 它可能是某个被托管菜单的后代。以端点结果为准。
手工验一遍三类操作
- 找一条带应用标签的菜单,把
path改一个字符 → 应被拒(对上面第一条原文); - 只改名称 / 图标 / 排序 / 显隐 → 应放行;
- 删除 → 先把它的子菜单和角色分配摘掉,再删,才会触达归属检查被拒。
跑下钻的单测
根 pom 默认 skipTests=true,必须显式打开并指定 profile:
& $mvn -pl wujies-modules/wujies-app-market -B test "-DskipTests=false" "-Dprofiles.active=dev" `
"-Dtest=AppMenuTreeExpanderTest"MenuOwnershipProvider 在测试源码里没有引用:SysMenuServiceImpl 的拦截本身没有自动化测试,
回归靠上面的单测(覆盖下钻)+ 手工实测。