菜单与权限
这一页解决什么问题:你在 app.json 里声明了一棵菜单树,安装后它会变成 sys_menu 里的行。
本页讲清四件事 —— 平台怎么写这些行(三条规则)、按钮为什么必须写进清单、
menu_key 的命名有哪些硬约束,以及这些行装完之后谁保护它们。
字段逐个的解释(key / name / type / path / component / perms / order / children 的类型与上限)
在 应用清单 app.json 的 menus[] 一节,本页不重复。
归属机制的设计推导在 菜单归属与保护。
一个真实的最小例子
wujies/wujies-plugins/wujies-hello/app.json 是范例应用(推荐照着它抄):
"menus": [
{
"key": "hello",
"name": "Hello 示例",
"type": "M",
"path": "hello",
"children": [
{
"key": "hello-plugin-dev",
"name": "插件开发范例",
"type": "C",
"path": "plugin-dev",
"component": "remote:hello/demo/plugin-dev",
"perms": "hello:plugindev:view",
"children": [
{ "key": "hello-plugindev-view", "name": "查看范例页", "type": "F",
"path": "", "perms": "hello:plugindev:view", "icon": "#", "order": 1 }
]
}
]
}
]一条铁律:不要手写 INSERT INTO sys_menu
菜单只能通过清单声明,安装 / 升级时由平台的 AppMenuPlanner 算出计划、幂等落库。
理由:菜单以 menu_key 为身份,同名 key 复用既有 menu_id。
手写 SQL 会绕开这个机制 —— 升级时插出一套重复菜单,或者让租户已经分配好的权限全部失效。
三条核心规则
| # | 规则 | 不这么做会怎样 |
|---|---|---|
| 1 | 按 key 幂等 —— 重复装同一版本不产生重复菜单 | 每次同步多一套菜单,每套的 menu_id 都不同 |
| 2 | 跨版本复用 menu_id —— 新版本里同名 key 沿用旧 menu_id | 租户已有的 sys_role_menu 授权指向旧 ID,升级后全部失效 |
| 3 | 深度优先、父先于子 —— 落库时父菜单 ID 必定已确定 | 子菜单的 parent_id 落成空或指向未创建的行 |
规则 2 的直接推论:已经发布过的 key 不要改。改 key 等于新菜单, 旧菜单会被判为 obsolete(作废)—— 平台只标记不动它, 调用方应当把它停用而不是删除(可能还有租户停留在旧版本)。
校验器还会拒绝什么
AppMenuPlanner 是纯逻辑类,清单不合法时在上传阶段就报错。以下都是它的真实文案:
| 报错 | 触发条件 |
|---|---|
第 N 个菜单 key 非法(需为小写字母开头的 2-63 位小写字母/数字/连字符): x | key 不匹配 ^[a-z][a-z0-9-]{1,62}$ |
菜单 key 重复: x | 同一份清单里 key 撞了 |
菜单 x 缺少 name / 菜单 x 缺少 type | 必填项缺 |
菜单 x 的 type 非法(只能是 M/C/F): y | type 写了别的值 |
菜单 x 是页面菜单(C),必须声明 component | C 类型没写 component |
按钮 x 必须声明 perms | F 类型没写 perms |
菜单树深度超过上限 5,请检查清单嵌套 | 嵌套超过 5 层 |
完整报错清单见 应用清单 app.json 的「校验报错文案速查」。
按钮(type: "F")必须写进清单
type: "F" 不是可选项。漏了会同时踩三个坑:
| 漏写的后果 | 表现 |
|---|---|
| 全新部署装上应用 | 没有按钮 —— 前端 v-hasPermi 过不了,租户用户看不到任何操作按钮 |
| 菜单管理页 | 按钮不受保护,管理员能随手删掉 |
| 「彻底删除」应用 | 按钮删不掉(归属映射里没有它),留成"父菜单没了的孤儿" |
第三条的根因:归属映射 app_version_menu 里只有清单声明过的菜单,
而平台侧的归属查询(AppMenuOwnershipProvider)要顺着 parent_id 把后代一起算成该应用的
(AppMenuTreeExpander)。清单里没有的那些按钮只能靠这个兜底被"连坐"认领;
一旦连坐关系也断了,管理员在菜单管理页就能随手删掉应用按钮,恢复只能靠重装。
把新 key 映射到既有的按钮 ID,否则下一次升级会建出一套重复按钮。
图床(gallery)的 23 个按钮就是这么补进
wujies/wujies-plugins/wujies-gallery/app.json 的,配套脚本是
wujies-platform/script/sql/update/fix_gallery_menu_tree.sql。它值得当模板看,因为一段做三件事:
| 节 | 做什么 | 关键点 |
|---|---|---|
| 1 | 用原 menu_id 插回被误删的菜单行 | 值取自 app.json(清单是唯一事实来源);父行按原 ID 回来,按钮的 parent_id 自动重新挂上 |
| 2 | 把新 key 登记进 app_version_menu(key → 既有 menu_id) | 下次升级按 key 复用这些既有按钮,而不是新建一套 |
| 3 | 把这批菜单授权给租户管理员角色 | 与安装时的授权行为一致(见下节) |
整个脚本用 INSERT ... WHERE NOT EXISTS / NOT EXISTS 写成幂等,可重复执行。
menu_key 的命名硬约束与转换
key 只能是小写字母开头的 2–63 位小写字母 / 数字 / 连字符(^[a-z][a-z0-9-]{1,62}$)。
但 perms 里普遍有驼峰 —— 直接拿 perms 当 key 会在上传时被拒:
第 N 个菜单 key 非法(需为小写字母开头的 2-63 位小写字母/数字/连字符): member-member-adjustPoints正确做法:kebab = 驼峰前插 '-' 再整体小写(member:balanceLog:query → member-balance-log-query)。
两者没有关系,别一起转。真实清单里 key 全小写连字符,path 保留驼峰:
{"key": "gallery-image-category", "path": "imageCategory"}、
{"key": "gallery-image-access-log", "path": "imageAccessLog"}(见 gallery 的 app.json)。
path 是前端路由地址,与 权限标识 无关。
菜单多的模块要"生成"而不是手写
会员 68 行、商城更多。手写必然出错,而且有三个必须避开的陷阱。
陷阱一:app.json 与 seed SQL 必须同源
两份手写必然漂移,而漂移的后果是升级时建出一套重复菜单。
必须由同一份数据生成这两样东西:先从库里读整棵菜单树 →
一次生成 app.json 与 menu_key → menu_id 映射 → 再由映射生成 seed SQL。
陷阱二:F 行的 key 要父键限定
同一个 perms 可能在多行菜单上重复出现(工作台一套按钮、各管理页又各一套)。
生成 F 行 key 时不能只用 perms:
- 用父键限定(
im-workbench-<perms 尾>与im-conversation-<perms 尾>); - 再加
-<menu_id>兜底; - 最后断言同版本内 key 唯一(不唯一的话上传就会被
菜单 key 重复拒掉)。
生成前先自查:
SELECT perms, COUNT(*) FROM sys_menu
WHERE perms LIKE '<code>:%' GROUP BY perms HAVING COUNT(*) > 1;陷阱三:菜单要"从根整棵取"
用递归 CTE 取整棵子树,不要按 perms LIKE '<code>:%' + path 过滤 ——
社交那一轮因此丢掉了 2 个中间目录 + 11 个页面(中间目录既没有前缀 perms,path 里也不含应用编码)。
中间目录的 key 用整条路径转 kebab。
权限标识:代码里和清单里都要写
后端在控制器上标 @SaCheckPermission("<perms>"),清单里的 menus[].perms 写同一个字符串,
两边必须逐字一致。范例应用刻意没有加注解(好让沙箱里不带 token 也能验通),
它的注释明写了真实插件该怎么写 ——
见 wujies/wujies-plugins/wujies-hello/src/main/java/cn/wujies/hello/controller/HelloController.java:
// 真实插件应当加 @SaCheckPermission("hello:item:list")(与 app.json 里菜单的 perms 对应)表现为「菜单看得见但点进去 403」:菜单行的 perms 为空 → 没有授权可发 → 租户角色拿不到权限。
授权是按角色发的,清单只能表达一个角色
安装 / 升级时,平台把该版本的菜单 授权给"安装者所属租户"的管理员角色
(AppMenuGrantServiceImpl:优先 admin,系统默认租户 000000 没有 admin 时回退 superadmin;
两者都没有则告警)。所以:
- 清单里没有"授给哪些角色"这种表达 —— 安装只会授给一个角色;
- 要让别的角色也看到这些菜单,得另写一条授权 SQL(幂等,按
menu_key定位菜单), 参考wujies-platform/script/sql/update/finance_menu_grants.sql。
菜单归属与保护
应用装的菜单在 sys_menu 里是普通行。管理员在「系统管理 → 菜单管理」里
可以直接改掉 path / component / perms,或直接删掉 ——
改一个字段页面就打不开、权限就失效;删掉之后除非重装 / 升级应用,菜单再也回不来。
归属不需要在 sys_menu 上加列:菜单同步时已经把 (version_id, menu_id, menu_key)
写进了 app_version_menu,那就是权威记录。AppMenuOwnershipProvider 把它反查成「menuId → 应用」:
app_install(本租户装了的应用) → app_info(编码/名称)
→ app_version(这些应用的版本) → app_version_menu(版本 → 菜单)| 角色 | 位置 |
|---|---|
| 契约 | wujies-common-core 的 MenuOwnershipProvider(通用模块不能反向依赖 app-market) |
| 实现 | app-market 的 AppMenuOwnershipProvider(四张表反查 + 后代扩展;租户过滤交给 MyBatis-Plus 插件,不 ignore) |
| 拦截 | SysMenuServiceImpl 的 updateMenu / deleteMenuById(ObjectProvider 注入,缺席即"没有受保护菜单") |
| 标注 | GET /app/console/owned-menus(权限 system:menu:list)+ 菜单管理页 |
保护策略:只挡会坏的,不挡想调的。
| 操作 | 允许? | 理由 |
|---|---|---|
改 path / component / perms / menuType / parentId | ❌ 拒绝 | 与插件代码、前端路由、权限标识绑死 |
| 改名称 / 图标 / 排序 / 显隐 | ✅ 放行 | 纯展示属性,管理员按习惯调没有风险 |
| 删除 | ❌ 拒绝 | 删了只能靠重装 / 升级恢复;想隐藏请「停用」 |
三个容易搞错的点:
- 保护在服务端。菜单管理页只是把路由/权限字段禁掉、删除按钮禁掉,接口是同权限可调的
(其它客户端、直接 curl),页面禁用不是安全边界,收口点是
SysMenuServiceImpl。 - 框架自带的检查会先拦住大多数删除:
存在子菜单,不允许删除与菜单已分配,不允许删除都发生在进入 service 之前。所以实测时必须先摘掉角色分配才能触达归属检查 —— 归属检查是最后一道,专治"菜单成了孤儿"。 - 停用(disabled)的应用,它的菜单仍受保护。归属查询只排除已删除的安装记录, 不排除停用的 —— 它随时可能被重新启用。
卸载与归属的关系
- 卸载(保留数据,默认):菜单行保留,靠
AppMenuFilterImpl按"归属本应用但本租户未启用"把菜单隐藏;本租户的角色授权被回收。 - 彻底删除(
purge=true):删菜单时必须连后代一起删(AppMenuTreeExpander), 否则按钮成孤儿。清归属映射按三条线索并行:menu_id ∈ 本次删掉的菜单∪menu_key ∈ 清单声明的键∪version_id ∈ 该应用已知版本—— 少一条就留下"死映射", 下次安装复用它指向的已删除菜单 ID,结果是「安装成功、状态 enabled,但一个菜单都没有」。
菜单树同步的时机
前端那份菜单树只在登录时生成一次。 所以:
- 装完 / 升级 / 启用 / 停用 / 卸载后,前端都要刷新菜单树
(
plus-ui/src/plugins/appmarket/menuRefresh.ts的refreshMenus()), 否则工作台与侧边栏看不到变化,用户只能手动刷新; - 工作台另外做了自愈:发现已安装应用在旧树里找不到入口,就先重建再判断。
POST /app/install/{installId}/sync-menus(权限 app:install:edit)重跑该安装实例的菜单同步。
映射表为空时它会按结构认领既有菜单(M/C 用 (parentId, path)、F 用 (parentId, perms) 匹配),
而不是把清单里每个菜单都当新建 —— 后者实测会插出第二套菜单树。
误建的行用 wujies-platform/script/sql/update/cleanup_duplicate_app_menus.sql 清理(幂等)。