
你刚在低代码设计器里把「订单页」点了发布,应用版本也绿了。打开「应用权限」,角色勾选看起来齐全;业务同学打开 /app/xxx 门户,导航却是空的,或点进去直接没权限。值班群第一反应往往是:「是不是没挂到系统菜单?」
Forge Admin 在 2026-10-20 的三连提交(25728d85e / cdf864d53 / ee00b92a4)里,把这件事钉成一条产品契约------门户入口权限跟系统菜单挂载是两条链路;发布就该注册可授权资源,不必先挂系统侧栏。 下面按仓库真实源码与单测写;推断处标「按源码推演」。
查重:和 2026-05 那篇「RBAC 权限链路拆解」、以及近期 Boot4 / Flyway / 插件 FeatureGate 都不是同一题。本篇只谈应用内页面发布 → 门户入口资源注册 → 页面级授权,不讲系统管理端菜单树本身的鉴权中间件。
TL;DR
- 故障:页面已发布,权限面板「看着对」,用户进应用门户仍进不去 / 导航空白。
- 误判:以为必须「挂载系统菜单」才有权限资源可勾。
- 根因(修复前) :
BusinessApplicationPageMenuPublishService只对systemMenuVisible=true且已选父级的节点注册资源;未挂载页在工作台被滤掉。 - 修复 :每个 page/group 发布即注册权限资源(默认
visible=false);另注册root门户节点;menu_status始终启用,visible只控系统侧栏。 - 产品层 :发布面板文案澄清「互不依赖」;权限工作台改成纯页面一级(对象功能挂在页面卡片下)。
- 自查 :先看是否已发布 → 角色是否勾了
ai:business:application:{code}:page:{id}→ 再谈要不要挂系统菜单。
现象:三件事同时成立时,人最容易误判
低代码交付现场,常是下面三件事同时成立:
- 设计器里页面状态是「已发布」,应用协调发布成功;
- 「应用权限」里能看到页面名,或者部分勾选看起来合理;
- 业务角色登录应用门户(
/app/{门户标识})后,导航缺页或进不去。
人脑直觉会把「系统菜单」当成权限的唯一载体------毕竟传统后台管理系统里,菜单资源 ≈ 路由 ≈ 权限码。Forge 的应用门户不是那条路:门户有独立布局与导航树,页面入口资源可以隐藏在系统侧栏之外,只给角色授权、只在门户内过滤。
发布面板旧文案强化了误判(修复见 cdf864d53):系统菜单挂载默认关闭时,运维/实施会以为「没挂 = 没入口」。新文案直接写明:
只影响是否出现在系统左侧菜单。应用门户内的页面入口由「应用权限」授权,发布后即可勾选,不必挂载系统菜单。
未选父级时的提示也改成:「不会进系统菜单,但应用门户入口仍可按角色授权」。
根因一:发布只给「已挂载」页面注册权限资源
修复前,BusinessApplicationPageMenuPublishService 构建同步清单时,大致是:遇到页面组就跳过;未开 systemMenuVisible 就跳过;开了挂载但没选父级也 continue。结果是------没挂系统菜单的页面,发布后根本没有 sys_resource 可授权。
修复后(25728d85e,类路径 .../businessapp/BusinessApplicationPageMenuPublishService.java)核心逻辑变成:
java
// 门户 RBAC:每个页面/页面组都注册可授权资源(默认隐藏,不进系统侧栏)。
// 仅「挂载系统菜单 + 已选父级」时才写成可见系统菜单;未选父级的假挂载降级为隐藏入口,避免发布失败。
boolean mounted = !directory && systemMenuVisible(node) && externalMenuParentId != null;
BusinessApplicationPageMenuDTO item = menu(
nodeId,
mounted ? externalMenuParentId : null,
title,
menuPath,
menuComponent,
permission(applicationCode, nodeId),
string(node.get("icon")),
sort,
directory,
mounted, // visible = mounted
clientCode);
item.setExternalParent(mounted);
权限码格式(同文件 permission(...)):
java
return "ai:business:application:" + applicationCode + ":page:" + nodeId;
另外为每个 client(pc / h5)补一个门户根节点:
java
BusinessApplicationPageMenuDTO root = menu(
"root", null,
applicationName + "门户",
"/app/" + portalIdentifier,
PORTAL_COMPONENT, // "app-center/application-portal"
permission(applicationCode, "root"),
null, 0, true, false, clientCode);
menus.add(0, root);
单测 registersHiddenPortalEntryWhenSystemMenuNotMounted 明确断言:未挂载时页面仍进入 syncApplicationPageMenus,且 !page.isVisible()、!page.isExternalParent(),同时清单里存在 root。
按源码推演 :以前「权限面板看着对」可能来自对象级按钮权限或其它模块勾选,页面入口本身从未注册;也可能注册了但门户过滤用的是另一套 page:{id} 码------两边对不上更难排。

根因二:权限工作台把「未挂载」页滤掉了
同一提交里,BusinessApplicationPermissionService 构建工作台目录时,去掉了对 systemMenuVisible 的过滤,改为按节点类型收录 page / group:
java
// 应用内页面入口授权不依赖「挂载系统菜单」;未发布时仍展示为待发布,发布后即可勾选。
List<Map<String, Object>> visibleNodes = maps(builder.get("nodes")).stream()
.filter(node -> StringUtils.isNotBlank(string(node.get("id"))))
.filter(node -> {
String type = StringUtils.defaultString(string(node.get("type"))).trim().toLowerCase();
return "page".equals(type) || "group".equals(type)
|| "page-group".equals(type) /* ... 其它组类型别名 ... */;
})
.toList();
单测 includesPortalPagesWithoutSystemMenuMount:systemMenuVisible=false 的 portal_only 页在工作台里 registered=true,权限码为 ai:business:application:ORDER_APP:page:portal_only。
配套适配层 MenuRegisterAdapterImpl 也改了一刀:同步清单内的资源始终 menu_status=启用,visible 才跟「是否进系统侧栏」走------
java
// 同步清单内的资源始终保持 menu_status=启用,便于角色授权;
// visible 只控制是否进入系统侧栏(门户入口常用 visible=0 的隐藏资源)。
resource.setMenuStatus(EnableStatus.ENABLED.getCode());
resource.setVisible(item.isVisible() ? EnableStatus.ENABLED.getCode() : EnableStatus.DISABLED.getCode());
这点反直觉但关键:禁用菜单状态会让角色授权侧选不到;隐藏可见性才是「不进系统侧栏但仍可授权」的正确语义。
根因三:运行时把页面组当成必须单独授权的入口
门户运行时过滤在 BusinessApplicationRuntimeService.accessAllowed。25728d85e 补充:页面组只承担导航结构,入口授权落在子页面上(与权限工作台「结构组隐式」一致):
java
if ("group".equals(type) || "page-group".equals(type) /* ... */) {
return true;
}
String permission = "ai:business:application:" + normalizedApplicationCode + ":page:" + nodeId;
return permissionCodes.contains(permission)
|| permissionCodes.contains("*:*:*")
|| permissionCodes.contains("**");
单测同步改成:过滤 builder 时只给子页面 page_sales 权限码,不必再带 group_sales,组节点仍会因祖先补齐出现在导航里(includeAncestors)。
按源码推演:若实施同学按「每个树节点都要勾」去配,容易在组节点上反复纠结「为什么勾了还是空」------其实该勾的是叶子 page。
根因四:授权 UI 仍是「页面入口 / 业务对象」两栏,认知成本高
ee00b92a4 把前端 application-permission-utils.js 的 buildApplicationPermissionModules 改成单一「页面」模块:页面勾选 = 门户可见;绑定对象的功能权限与数据范围挂在同一页面卡片下,不再分两栏。
js
/**
* 应用权限工作台:按页面一级配置。
* 页面勾选 = 门户可见;绑定对象的功能权限与数据范围挂在同一页面卡片下,
* 不再分「页面入口 / 业务对象」两栏。
*/
export function buildApplicationPermissionModules(workspace = {}) {
// ...
return [{
key: 'application:pages',
name: '页面',
path: '按页面配置访问、功能权限与数据范围',
pages,
}]
}
后端工作台 VO 增加 objectCode / objectId / objectName(来自节点 objectRef),方便把对象能力挂到页面卡片。面板文案改为:
按页面授权:勾选页面即可在应用门户看到该页(与是否挂载系统菜单无关)。页面绑定的功能权限会随勾选联动;数据范围仍需在页面卡片内单独配置。
同提交还修了发布 Tab 串出页面预览:showRuntimePageSurface 在草稿预览切到 settings/publish 时隐藏页面预览,避免叠层串样式------这是体验坑,不计入权限根因,但和「发布」操作同屏,写进变更说明方便对照提交。
怎么改:三条提交各自解决什么
按时间线读会更清晰(均为 2026-10-20,Asia/Shanghai):
| Commit | 主要改动面 | 解决的问题 |
|---|---|---|
25728d85e |
发布服务 + 权限工作台 + Runtime 过滤 + MenuRegisterAdapter + 单测 | 发布即注册隐藏入口;未挂载也可授权;组节点结构隐式 |
cdf864d53 |
PageDesignPublishPanel.vue / ApplicationPermissionsPanel.vue 文案 |
产品语义写清:挂载可选,门户授权另算 |
ee00b92a4 |
application-permission-utils.js + 工作台 VO/Service + Tab 预览 |
授权 UI 收成纯页面级;对象能力挂页面下;修发布 Tab 串预览 |
落地到自己的分支时,建议对照仓库单测,而不是只看 UI:
BusinessApplicationPageMenuPublishServiceTest:未挂载仍注册、挂载时visible=true、清单含rootBusinessApplicationPermissionServiceTest:portal_only可授权;页面暴露objectRefapplication-permission-utils.spec.js:模块长度为 1、名为「页面」;绑定对象嵌在页面卡片page-design-tabs.spec.js:showRuntimePageSurface在 settings/publish 隐藏预览
若你在自研平台做同类能力,最小契约可以压成一句:发布产物里必须带「可授权的页面资源 ID」;系统菜单只是其中一个消费者,不是唯一生产者。
自查清单
| 检查项 | 期望 | 不对时优先做什么 |
|---|---|---|
| 应用 / 页面是否已发布 | 工作台该页 registered=true,不是「待发布」 |
重新协调发布,确认 pageMenu 绑定写出 |
| 权限码是否存在 | ai:business:application:{应用编码}:page:{页面id} |
查发布清单是否含该 node;看适配层 sync 日志 |
| 角色是否勾了该页面 | 用户重新登录后门户导航出现 | 在「应用权限」按页面勾选,勿只勾对象按钮 |
| 页面组是否单独纠结 | 组节点结构隐式,授权落在子 page | 给叶子页授权即可 |
| 是否误以为要挂系统菜单 | 门户入口与系统菜单互不依赖 | 先完成上列;挂载只为进系统左侧菜单 |
menu_status / visible |
资源启用 + 门户隐藏入口 visible=0 合法 |
不要为了「侧栏看不见」去禁用整条资源 |

可带走的诀窍
- 门户 RBAC 的最小闭环 :发布注册
page:*(含隐藏)→ 角色勾页面 → 用户重登 → 门户filterBuilder按权限码过滤。系统菜单是可选旁路。 - 语义拆开 :
menu_status管「能不能授权」,visible管「出不出现在系统侧栏」。把隐藏入口写成「禁用」会把自己坑死。 - 授权粒度对齐心智模型:业务同学想的是「这个人能不能进这个页」;UI 就按页面一级,把对象功能挂在页下面,而不是逼人先理解两套模块。
- 排障顺序:发布态 → 资源是否注册 → 角色授权 → 登录会话是否刷新;最后才查系统菜单挂载。
写在最后
低代码平台里,「发布」若不同时产出可授权资源,实施同学只能拿系统菜单当拐杖------拐杖用久了就变成错误模型。Forge Admin 这次把拐杖放回可选架:门户入口跟系统侧栏解耦,应用授权收成纯页面级。若你也在做「应用内页面 + 独立门户」这一层,欢迎对照上述类与单测;踩过类似坑的话评论区聊聊。
觉得有用请点赞、收藏,方便下次排障时翻到。开源仓库与文档:
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
- 文档:www.dlforgelab.com:8084/forge-docs/
- 演示:www.dlforgelab.com:8084/forge/login
(本文涉及提交:25728d85e、cdf864d53、ee00b92a4。)