Wujies 无界平台
插件开发advanced

菜单与权限

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

这一页解决什么问题:你在 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 位小写字母/数字/连字符): xkey 不匹配 ^[a-z][a-z0-9-]{1,62}$
菜单 key 重复: x同一份清单里 key 撞了
菜单 x 缺少 name / 菜单 x 缺少 type必填项缺
菜单 x 的 type 非法(只能是 M/C/F): ytype 写了别的值
菜单 x 是页面菜单(C),必须声明 componentC 类型没写 component
按钮 x 必须声明 permsF 类型没写 perms
菜单树深度超过上限 5,请检查清单嵌套嵌套超过 5 层

完整报错清单见 应用清单 app.json 的「校验报错文案速查」。

按钮(type: "F")必须写进清单

type: "F" 不是可选项。漏了会同时踩三个坑:

漏写的后果表现
全新部署装上应用没有按钮 —— 前端 v-hasPermi 过不了,租户用户看不到任何操作按钮
菜单管理页按钮不受保护,管理员能随手删掉
「彻底删除」应用按钮删不掉(归属映射里没有它),留成"父菜单没了的孤儿"

第三条的根因:归属映射 app_version_menu 里只有清单声明过的菜单, 而平台侧的归属查询(AppMenuOwnershipProvider)要顺着 parent_id 把后代一起算成该应用的 (AppMenuTreeExpander)。清单里没有的那些按钮只能靠这个兜底被"连坐"认领; 一旦连坐关系也断了,管理员在菜单管理页就能随手删掉应用按钮,恢复只能靠重装。

对已有部署要配一条 seed SQL

把新 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 写成幂等,可重复执行。

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 是 kebab,path 不是

两者没有关系,别一起转。真实清单里 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❌ 拒绝与插件代码、前端路由、权限标识绑死
改名称 / 图标 / 排序 / 显隐✅ 放行纯展示属性,管理员按习惯调没有风险
删除❌ 拒绝删了只能靠重装 / 升级恢复;想隐藏请「停用」

三个容易搞错的点:

  1. 保护在服务端。菜单管理页只是把路由/权限字段禁掉、删除按钮禁掉,接口是同权限可调的 (其它客户端、直接 curl),页面禁用不是安全边界,收口点是 SysMenuServiceImpl。
  2. 框架自带的检查会先拦住大多数删除:存在子菜单,不允许删除 与 菜单已分配,不允许删除 都发生在进入 service 之前。所以实测时必须先摘掉角色分配才能触达归属检查 —— 归属检查是最后一道,专治"菜单成了孤儿"。
  3. 停用(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 清理(幂等)。

相关阅读

目录