页面已发布用户仍进不去:Forge Admin 门户入口与系统菜单挂载解耦

你刚在低代码设计器里把「订单页」点了发布,应用版本也绿了。打开「应用权限」,角色勾选看起来齐全;业务同学打开 /app/xxx 门户,导航却是空的,或点进去直接没权限。值班群第一反应往往是:「是不是没挂到系统菜单?」

Forge Admin 在 2026-10-20 的三连提交(25728d85e / cdf864d53 / ee00b92a4)里,把这件事钉成一条产品契约------门户入口权限跟系统菜单挂载是两条链路;发布就该注册可授权资源,不必先挂系统侧栏。 下面按仓库真实源码与单测写;推断处标「按源码推演」。

查重:和 2026-05 那篇「RBAC 权限链路拆解」、以及近期 Boot4 / Flyway / 插件 FeatureGate 都不是同一题。本篇只谈应用内页面发布 → 门户入口资源注册 → 页面级授权,不讲系统管理端菜单树本身的鉴权中间件。

TL;DR

  1. 故障:页面已发布,权限面板「看着对」,用户进应用门户仍进不去 / 导航空白。
  2. 误判:以为必须「挂载系统菜单」才有权限资源可勾。
  3. 根因(修复前) :BusinessApplicationPageMenuPublishService 只对 systemMenuVisible=true 且已选父级的节点注册资源;未挂载页在工作台被滤掉。
  4. 修复 :每个 page/group 发布即注册权限资源(默认 visible=false);另注册 root 门户节点;menu_status 始终启用,visible 只控系统侧栏。
  5. 产品层 :发布面板文案澄清「互不依赖」;权限工作台改成纯页面一级(对象功能挂在页面卡片下)。
  6. 自查 :先看是否已发布 → 角色是否勾了 ai:business:application:{code}:page:{id} → 再谈要不要挂系统菜单。

现象:三件事同时成立时,人最容易误判

低代码交付现场,常是下面三件事同时成立:

  1. 设计器里页面状态是「已发布」,应用协调发布成功;
  2. 「应用权限」里能看到页面名,或者部分勾选看起来合理;
  3. 业务角色登录应用门户(/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、清单含 root
  • BusinessApplicationPermissionServiceTest:portal_only 可授权;页面暴露 objectRef
  • application-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 合法 不要为了「侧栏看不见」去禁用整条资源

可带走的诀窍

  1. 门户 RBAC 的最小闭环 :发布注册 page:*(含隐藏)→ 角色勾页面 → 用户重登 → 门户 filterBuilder 按权限码过滤。系统菜单是可选旁路。
  2. 语义拆开 :menu_status 管「能不能授权」,visible 管「出不出现在系统侧栏」。把隐藏入口写成「禁用」会把自己坑死。
  3. 授权粒度对齐心智模型:业务同学想的是「这个人能不能进这个页」;UI 就按页面一级,把对象功能挂在页下面,而不是逼人先理解两套模块。
  4. 排障顺序:发布态 → 资源是否注册 → 角色授权 → 登录会话是否刷新;最后才查系统菜单挂载。

写在最后

低代码平台里,「发布」若不同时产出可授权资源,实施同学只能拿系统菜单当拐杖------拐杖用久了就变成错误模型。Forge Admin 这次把拐杖放回可选架:门户入口跟系统侧栏解耦,应用授权收成纯页面级。若你也在做「应用内页面 + 独立门户」这一层,欢迎对照上述类与单测;踩过类似坑的话评论区聊聊。

觉得有用请点赞、收藏,方便下次排障时翻到。开源仓库与文档:

(本文涉及提交:25728d85e、cdf864d53、ee00b92a4。)

相关推荐
不像程序员的程序媛1 小时前
mybatisplus insert dto未指定id时会如何
java·开发语言
我命由我123451 小时前
5-2.Python 编码进阶 - 字符串进阶(拼接、普通格式化、普通格式化数字精度控制、快速格式化、遍历、常用方法)
java·开发语言·后端·python·学习·java-ee·学习方法
专业程序开发源1 小时前
django视听资源推荐系统16690-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·php·课程设计
Pniubi9 小时前
HashMap的扩容和树化边界
java·开发语言
Mikko79 小时前
用 Java 复刻了高中时在塞班上玩的「熔岩矿车」:纯 Swing、零依赖、没有一张图片,一个 jar 双击就能玩
java·游戏开发·swing
知了学历孙老师9 小时前
小自考的校考和统考,出题方和难度差在哪
java·大数据·前端
Java爱好狂.10 小时前
Java初学者如何一周速通SpringBoot!
java·spring boot·后端·spring·高并发·架构师·java面试
秋饼11 小时前
LLM API密钥动态轮换实战:Spring Vault + Spring AI + 多Provider路由
java·ai·技术分享·后端开发
福兮说12 小时前
JSON 格式正确,数据就可靠吗?我用一份自编配置检查类型与空值
java·前端·json