Wujies 无界平台
平台功能(自研)应用市场

菜单归属与保护

这一页解决什么问题:应用声明的菜单落进 sys_menu 之后就是普通行 —— 「系统管理 → 菜单管理」里谁都能改 path / component / perms,也能直接删掉。 本页讲清三件都能自己核对的事:

  1. 平台怎么认出一行菜单属于哪个应用(四张表反查,不需要给 sys_menu 加列);
  2. 哪些操作会被拒(只有三类操作,其中两类被拒,其余一律放行);
  3. 为什么归属必须连后代一起算(按钮那层不在映射表里)。

菜单清单本身怎么写不在这里,见 菜单与权限 与 应用清单 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 / deleteMenuByIdObjectProvider<MenuOwnershipProvider> 注入,缺席即"没有受保护菜单"
标注GET /app/console/owned-menus(权限 system:menu:list)+ 宿主前端 plus-ui/src/views/system/menu/index.vue菜单列表里打应用名标签、禁用删除按钮与路由字段
为什么不在 sys_menu 上加一列 app_code

要动框架的实体 / 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 行、映射照样写回。

两处修法(都在代码里,缺一不可):

#位置做什么
1wujies-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(...) 先过两道闸:

  1. 别的租户还在用就不动全局的东西:还有别的 app_install 记录(del_flag='0' 且状态非 uninstalled,且排除自己那一条)时,菜单 / 表 / 迁移记录 / 产物全部跳过, 只把原因写进任务日志;
  2. 平台表前缀受保护:只删迁移脚本声明过的表,命中 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>;

查不到不等于不受保护 —— 它可能是某个被托管菜单的后代。以端点结果为准。

手工验一遍三类操作

  1. 找一条带应用标签的菜单,把 path 改一个字符 → 应被拒(对上面第一条原文);
  2. 只改名称 / 图标 / 排序 / 显隐 → 应放行;
  3. 删除 → 先把它的子菜单和角色分配摘掉,再删,才会触达归属检查被拒。

跑下钻的单测

根 pom 默认 skipTests=true,必须显式打开并指定 profile:

& $mvn -pl wujies-modules/wujies-app-market -B test "-DskipTests=false" "-Dprofiles.active=dev" `
    "-Dtest=AppMenuTreeExpanderTest"

MenuOwnershipProvider 在测试源码里没有引用:SysMenuServiceImpl 的拦截本身没有自动化测试, 回归靠上面的单测(覆盖下钻)+ 手工实测。

相关阅读

目录