Skip to content

服务端驱动导航:system_nav_node 全链路 ​

适用端:后端 + Web + 移动端 | 类型:机制

定制功能

system_nav_node(分端导航树)是本项目自研的,上游 ruoyi-vue-pro 与 yudao-ui-admin-uniapp 都没有这张表、也没有对应端点。它和 Web 端那套菜单树 system_menu 是两张表、两套树、两个维护入口。

移动端工作台里能看到哪些入口,不由前端代码决定,而由服务端 system_nav_node 表在 terminal='app' 下的那一棵树决定:

  • 谁能在 Web 后台维护这棵树?改完移动端要不要重新发版?
  • 后端凭什么判断某个节点该不该给当前用户看?停用一个分组,它的子项会自动消失吗?
  • 这张表和 Web 端的 system_menu 是什么关系?为什么不用一张表加个 terminal 字段?
  • 一个在线表单怎么「发布到 App 端」,让它出现在工作台里?

这一页把从建表到移动端渲染的完整链路走一遍,并把当前本地库里的实测树形列出来(它和种子脚本声明的形状不一致,原因见数据模型)。

前置阅读:移动端总览、权限体系总览(授权引擎 grant-enabled 的两种模式)

它解决什么问题 ​

移动端工作台早期用的是仓库里一份手写的 menu.json:267 项、三层嵌套,纯前端资产。它有三个治不好的病——加一个入口要发一次版、权限过滤只能在前端用白名单硬编码、和 Web 端菜单是两份独立真相必然漂移。

system_nav_node 把这三件事一起解决:菜单变成数据,于是维护入口在 Web 后台、权限判定在服务端、和 Web 菜单的关系由 menu_ref_id 显式标注而不是靠人对齐。

关键分工:Web 编辑器读的是未过滤的 /list(否则管理员看不到自己没权限的节点、没法维护),移动端读的是过滤后的 /tree。两个端点刻意分开,见接口清单。

代码在哪 ​

端位置作用
后端enlev-module-system/.../dal/dataobject/permission/NavNodeDO.java(113 行)DO,@TableName("system_nav_node") + @TenantIgnore + ID_ROOT = 0L
后端.../dal/mysql/permission/NavNodeMapper.java4 个 default 查询方法,全部走 MyBatis-Plus 逻辑删除
后端.../service/permission/NavNodeServiceImpl.java(346 行)CRUD + 建树剪枝(:139-231)+ terminal 校验(:238)+ NOT NULL 兜底(:250)+ 成环校验(:313)
后端.../controller/admin/permission/NavNodeController.java(177 行)7 个端点 + 权限集合解析(:156-175)
后端.../controller/admin/permission/vo/navnode/5 个 VO:Save / Resp / TreeResp / ListReq / SortReq
后端.../enums/permission/NavNodeTypeEnum.java、NavTerminalEnum.java节点类型 1/2/3;端标识只有 APP("app")
后端sql/mysql/update/2026-10-24-system-nav-node.sql:29-53建表 DDL
后端sql/mysql/update/2026-10-24-{system-nav-node-menu,system-nav-node-seed,nav-node-prune-undelivered,form-design-app-nav,menu-manage-split-web-app}.sql注册编辑器菜单、灌种子、裁未交付模块、加 app_nav_node_id、拆 Web/App 菜单
Websrc/views/system/navNode/index.vue(458 行)「App菜单」列表页,用 el-table-v2 虚拟表格承载树
Websrc/views/system/navNode/NavNodeForm.vue(340 行)新增/修改弹窗
Websrc/views/system/navNode/{constants.ts,wotIcons.ts,WotIconSelect.vue}端标识与类型常量、wot-ui 图标名清单(351 行)、图标选择器
Websrc/api/system/navNode/index.ts7 个函数(其中 updateNavNodeSort 无调用方)
Websrc/views/infra/formDesign/components/PublishDialog.vue:186,358,376「发布到 App 端」:列节点 → 建节点 → 绑定
移动端src/api/system/navNode/index.ts(40 行)NavNodeTree 接口 + getNavTree(terminal='app')
移动端src/store/nav.ts(68 行)loadNavTree(force) / clearNav,persist: true
移动端src/pages/index/index.ts(212 行)导航树 → 三层菜单结构的映射 + 跳转
移动端src/store/token.ts:115-127登录成功后 await loadNavTree(true),排在拉用户信息之后

核心机制 ​

1 表结构:每端一棵自引用树 ​

sql
`terminal`    varchar(16)  NOT NULL              -- 端标识:现阶段仅 app
`parent_id`   bigint       NOT NULL DEFAULT 0    -- 0=根;每端一棵自引用树
`node_type`   tinyint      NOT NULL              -- 1=分组 2=子分组 3=导航项
`permissions` varchar(512) NULL DEFAULT ''       -- 逗号分隔,任一命中即可见;空=不设门槛
`menu_ref_id` bigint       NULL DEFAULT NULL     -- 可选:指回 system_menu.id
`is_tabbar`   bit(1)       NOT NULL DEFAULT b'0' -- 决定 switchTab vs navigateTo
`only_pc`     bit(1)       NOT NULL DEFAULT b'0' -- 移动端点击跳「仅 PC」提示页
`query`       varchar(255) NULL DEFAULT ''       -- 页面参数(如在线表单 code=xxx)
`visible`     bit(1)       NOT NULL DEFAULT b'1' -- 该端是否显示
`status`      tinyint      NOT NULL DEFAULT 0    -- 0 正常 / 1 停用(停用则整枝不展示)
INDEX `idx_terminal_parent`(`terminal`, `parent_id`)

三个设计取舍值得单说:

  • @TenantIgnore:这张表不分租户。NavNodeDO 上没有 tenant_id,NavNodeMapper 的注释写明「仿 MenuMapper」。含义是:导航树是平台级配置,不是租户级数据——所有租户看到同一棵树,差异只由 permissions 过滤出来。这和 system_menu 的口径一致,但和大多数业务表相反,写查询时别习惯性加租户条件。
  • permissions 是逗号分隔的串而不是关联表:换来的是「一个入口可挂多个权限、任一命中即可见」这种 OR 语义(NavNodeServiceImpl:217-231),代价是没有外键约束、权限码写错不会报错只会静默不展示。
  • menu_ref_id 是可选的弱关联:它只标记「这个移动端节点和某个 Web 菜单是同一个能力」,不参与鉴权、不级联删除。它由表单发布流程写入(PublishDialog.vue:366 建节点时带上刚建好的 Web 运行时菜单 id,:401 更新时 existing.menuRefId ?? menuRefId 保留原值),「App菜单」编辑器只在 formData 里透传、不提供输入框(NavNodeForm.vue:161,327)。后端没有任何代码读它。实测 20 个存活节点里只有 2 个填了值——正好是两个已发布到 App 的在线表单。

2 terminal 校验:挡住静默的空工作台 ​

NavTerminalEnum 只有 APP("app") 一个值。resolveTerminal(:238-244)做两件事:空则默认 app,非法则抛 NAV_NODE_TERMINAL_INVALID。

它的 javadoc 把动机说得很直白:

打错的 terminal 不会报错、只会让那棵树永远查不出来(静默的空工作台),故必须在这里挡住

这是故意的强校验。如果将来加新端(比如 pad),必须先往枚举里加值,否则请求会被拒;反过来说,「加了枚举值但没灌数据」也会得到一棵空树——两者都不会抛异常之外的提示,排查时先看这一层。

3 剪枝:三条门槛 + 分组级联 ​

getNavTree(:139-152)先按 terminal 取全量(selectListByTerminal 按 (sort, id) 升序,建树和展示都依赖这个稳定顺序),归拢成 parentId → children 的 LinkedHashMap,然后从 ID_ROOT 递归。

递归里的判据只有两处:

java
for (NavNodeDO node : children) {
    if (!passOwnGate(node, allowedPermissions)) continue;   // ① 自身门槛
    if (ITEM.equals(node.getNodeType())) {
        result.add(toBean(node));                            // ② 叶子:直接收
    } else {
        List<...> sub = buildVisibleChildren(node.getId(), ...);
        if (sub.isEmpty()) continue;                         // ③ 分组:空则整枝隐藏
        vo.setChildren(sub); result.add(vo);
    }
}

passOwnGate(:198-209)三条,短路顺序固定:

  1. status 停用 → 不展示;
  2. visible == FALSE → 不展示(null 视为可见,与 DDL 默认 b'1' 一致);
  3. permissions 命中(空串 = 不设门槛,直接过)。

「停用则整枝不展示」不需要单独级联一次——因为自身门槛里已经含了 status,停用的分组在这一层被 continue 掉、根本不会递归它的子节点。这是这段代码最值得注意的地方:级联隐藏是递归结构的副产品,不是额外逻辑。改这段时如果想着「加一个级联判断」,多半是多余的。

反过来,③ 那条「分组无可见子节点则整枝隐藏」是必须显式写的:一个启用且可见的分组,如果它的子项全被权限过滤掉了,不写这条就会在工作台留下一个空壳分组标题。

4 权限集合从哪来:刻意复刻,不抽公共方法 ​

/tree 端点上挂的不是 @PreAuthorize,而是 @DataPermission(enable = false)——注释写明「与 getPermissionInfo 一致:算权限这件事本身不该再被数据权限过滤一遍」。权限集合由 Controller 里的 resolveAllowedPermissions(getLoginUserId())(:156-175)算出来再传给 Service:

enlev.permission.grant-enabled权限集合来源
true(新授权引擎)grantDecisionService.listAllowedPermissions(userId)
false(legacy)userId → roleIds → 过滤禁用角色 → menuIds → 过滤停用菜单 → MenuDO.permission 串集合
userId == nullCollections.emptySet()

这段逻辑和 AuthController#getPermissionInfo 里的是同一套,但没有抽成公共方法,是刻意复刻的。:150-155 的注释给了理由:本次改造的红线是「不碰 AuthController」(Web 路由生成链零回归),抽公共方法就得改 getPermissionInfo 去调它,反而动了红线。

代价被明确记在注释里:两处需同步,漂移风险是「将来有人改了 getPermissionInfo 的权限来源却忘了这里」。这是本页最需要长期盯的一条——两边读的是同一批 Service、同一判据,但没有任何编译期或测试期约束能保证它们一起改。

5 Web 侧维护入口:「App菜单」 ​

菜单路径是 系统管理 ▸ 菜单管理 ▸ App菜单(2026-10-24-menu-manage-split-web-app.sql 把原来单一的「菜单管理」拆成了一个目录,下挂「Web菜单」和「App菜单」)。

index.vue 的几个实现要点:

  • 用 el-table-v2 虚拟表格而不是普通 el-table(:89 注释、:98):树平铺后行数可能上千,虚拟滚动才不卡;row-key="id"(:107)+ expandedRowKeys(:151)手工管展开态。
  • handleTree(:378)把 /list 返回的平铺数组拼成树。
  • constants.ts 里 NAV_TERMINAL = 'app' 是唯一写死端标识的地方,NAV_NODE_TYPE_OPTIONS 带 tag 颜色。
  • WotIconSelect.vue + wotIcons.ts 提供 wot-ui 图标名选择器——因为图标最终是移动端渲染的,Web 侧必须知道移动端的图标名清单,这 351 行常量表就是两端的图标契约。

🔴 updateNavNodeSort 没有任何调用方——拖拽排序未接线

后端 PUT /system/nav-node/update-sort(:102-108,「拖拽排序用,同一事务落库」)和 Web 侧 src/api/system/navNode/index.ts:52 的 updateNavNodeSort 都在,但 grep 全仓(Web + 移动端)零个调用点。

也就是说:排序功能目前是半成品——index.vue 没有拖拽交互,sort 只能通过「修改」弹窗逐个改。别以为接口存在就等于功能可用。要接线得同时补前端拖拽 UI 和批量提交,后端已就绪。

6 移动端消费链 ​

拉取时机是登录成功之后、进工作台之前,串在 token.ts 的 _postLogin(:115-127)里:

await fetchUserInfo()        // ① 用户信息
await loadNavTree(true)      // ② 导航树,force=true
void  loadDictCacheWithRetry() // ③ 字典,不 await

导航树是 await 的(阻塞登录完成),字典是 void 的(后台补)。这个差别是有意的:工作台一进去就要渲染菜单,等不起字典;但字典晚到只会让个别文案短暂显示原始值。

store/nav.ts 的 loadNavTree(force)(:31)有两个细节:

  • force=false 且已有缓存时直接返回,不发请求;persist: true 让它跨启动存活。
  • 请求失败时保留旧的持久化树,不清空。所以断网重进 App 仍能看到上次的工作台(点进去会失败,但界面不是空白)。

clearNav(:51)在登出时调用。

拿到树之后,pages/index/index.ts 只做结构映射,不做任何权限判断(:10-19 的文件注释明确写了这一点):

函数位置做什么
toItem:71-83叶子 → MenuItem;拼 route + ?query;onlyPc 为真时 url 换成 ONLY_PC_PAGE
toSubGroup:86-91子分组 → MenuSubGroup,只收 nodeType===3 的孩子
toGroup:99-119分组 → MenuGroup;直接挂在分组下的叶子统一折进一个 key 为 ${id}__direct、name 为空串的无标题子分组,好让四种布局都能平铺
getMenuGroups:127-134只取 nodeType===1 的根,映射后过滤掉空分组
getSearchableMenus:152拍平给全局搜索用
useMenuNavigate:169跳转:开发中提示 → 记最近使用 → tabbar 页走 switchTab、普通页走 navigateTo → 失败兜底

is_tabbar 这个字段就是为 useMenuNavigate 的分支存在的:uni.switchTab 不接受 query,所以带参数的 tabbar 页要先 setTabParams(query)(:191)把参数塞进 globalData,再 switchTab(:193)。

布局由 menuLayout(:57)控制,四种取值 section / accordion / sidebar / tabs,持久化 key 是 workbenchMenuLayout,默认 sidebar。四种布局共用同一份数据,只换渲染外壳。

7 与 system_menu 的边界 ​

system_menusystem_nav_node
服务对象Web 管理端的路由与菜单移动端工作台入口
树深度目录 / 菜单 / 按钮三层分组 / 子分组 / 导航项三层(形状像,语义不同)
是否分租户否否(@TenantIgnore)
权限判定@PreAuthorize 逐端点 + 前端 v-hasPermi建树时按 permissions 剪枝,只影响可见性
维护入口系统管理 ▸ 菜单管理 ▸ Web菜单系统管理 ▸ 菜单管理 ▸ App菜单
关联——menu_ref_id 单向弱引用

为什么不合成一张表加 terminal 字段:system_menu 的行同时承担「路由定义 + 权限点 + 按钮」三种角色,且被 Web 侧路由生成链深度依赖(NavNodeController:50-52 把「不碰 AuthController / getPermissionInfo」列为红线)。往里加 terminal 等于把移动端的形状强加给 Web 的路由链,回归面不可控。拆成两张表、用 menu_ref_id 标注同源能力,换来的是 Web 侧零改动。

permissions 只管可见性,不是鉴权

节点上填了 system:notice:query,只意味着「没有这个权限的用户在工作台看不到这个入口」。真正的拦截仍然靠后端端点上的 @PreAuthorize。

给节点填 permissions 不能替代给接口加权限——一个 permissions 为空、route 指向某页面的节点,任何登录用户都能点进去,进去之后能不能拿到数据完全取决于那个接口的注解。反过来说,把节点 permissions 填错(比如写成不存在的权限码)也不会报错,只会让所有人看不到它。

数据模型 ​

system_nav_node,21 列,DDL 在 sql/mysql/update/2026-10-24-system-nav-node.sql:29-53,表注释「分端导航树(每端一棵,独立于 system_menu)」。完整列清单见核心机制 1。

🔴 任何 count(*) 不带 deleted=0 都是错的

本地库实测(2026-10-25,ainb-professional-init):

口径行数
count(*) 全表97
deleted=1 墓碑77
deleted=0 存活20(分组 4 / 子分组 4 / 导航项 12)
GET /tree 实际返回19(AI 大模型 无子节点,被级联剪掉)

差了 4.8 倍。手写 SQL 排查时忘了 deleted=0,会得出「这棵树有 97 个节点」的荒谬结论。走 Mapper 的 default 方法不会踩(MyBatis-Plus 逻辑删除自动加条件),只有裸 SQL 会。

🔴 本地库的树形和种子脚本声明的不一致

2026-10-24-system-nav-node-seed.sql 头部写着规模是「4 分组 / 14 子分组 / 64 项」(由 menu.json 机械转换,并已按需求四裁掉 10 组未交付模块共 265 行)。

但本地库存活的只有 4 / 4 / 12 = 20 行。77 个墓碑的 updater 全是 1、update_time 集中在 2026-10-24 16:19–16:20——也就是说这些行是通过「App菜单」界面手工软删的,不是脚本删的。

推论有三条,都别忽略:

  1. 不要用种子脚本的行数当「当前应该有多少节点」的判据,它描述的是灌完种子那一刻的形状;
  2. Web 编辑器 index.vue:381 的注释「整棵树只有 82 行量级」同样已过期(4+14+64=82),存活量级是 20;
  3. 手工软删发生在本地库,没有对应的迁移脚本——换一台机器导 ainb-professional-init.sql + 跑 update/ 得到的是 82 行那棵树,两边工作台长得不一样。这不是 bug,但排查「我这儿有入口你那儿没有」时是第一个要问的。

当前本地存活树(20 行,实测;[G]=分组 [S]=子分组,⌂=tabbar 页):

系统管理(1)[G]
├─ 消息中心(12)[S]
│  └─ 通知公告(13)        /pages-system/notice/index            [system:notice:query]
└─ 审计日志(20)[S]
   ├─ 操作日志(21)        /pages-system/operate-log/index
   └─ 登录日志(22)        /pages-system/login-log/index

基础设施(23)[G]
├─ API 日志(24)[S]
│  ├─ 访问日志(25)        /pages-infra/api-access-log/index
│  └─ 错误日志(26)        /pages-infra/api-error-log/index
├─ 文档演示-设备台账(360)  /pages-infra/form-design/run/index    menu_ref_id=9000051144
└─ 列表左侧树示例(362)     /pages-infra/form-design/run/index    menu_ref_id=2617

工作流程(62)[G]
└─ 审批中心(63)[S]
   ├─ 我的流程(64) ⌂      /pages/bpm/index
   ├─ 待办任务(65) ⌂      /pages/bpm/index
   ├─ 已办任务(66) ⌂      /pages/bpm/index
   ├─ 抄送我的(67) ⌂      /pages/bpm/index
   └─ 请假查询(68)        /pages-bpm/oa/leave/index

AI 大模型(291)[G]  ← 无子节点,/tree 剪掉,工作台看不到

几个从这张树里读出来的事实:

  • 8 个存活节点的 permissions 是空串(占 12 个导航项里的 2/3)——即不设权限门槛,任何登录用户都能看到。有权限串的只有 通知公告 和两个表单运行页。
  • 4 个 tabbar 项的 route 全是同一个 /pages/bpm/index,靠 query 区分(我的流程 / 待办 / 已办 / 抄送)。这就是 setTabParams + switchTab 那条路径存在的原因。
  • ONLY_PC_PAGE(only_pc=1)实测存活 0 行——字段和前端分支都在,但没有数据在用。
  • 两个 form-design/run 节点的权限码形如 infra:form-design-run:<模型标识>:query,是在线表单在移动端那套按模型生成权限码的产物。
  • 这 20 个节点的 route 全部命中 pages.json(12 个导航项去重后是 8 条路由,逐条核过)。这不保证将来也对——后端不校验,见怎么扩展里的核对脚本。

关联表 infra_form_design.app_nav_node_id(由 2026-10-24-form-design-app-nav.sql 加):实测 41 行有 Web 菜单绑定,其中只有 3 行填了 app_nav_node_id(表单 2247→361、2249→362、880501→360)。

🔴 表单 2247 的 app_nav_node_id=361 指向一个不存在的节点

361 不在上面那 20 个存活节点里。也就是说表单 2247 记录着「我已发布到 App 端」,但那个导航节点已经被软删了——移动端工作台看不到它,Web 侧却仍显示为已发布状态。

根因是两条路径互不通知:NavNodeServiceImpl#deleteNavNode(:76-86)只校验「有没有子节点」,不会反查 infra_form_design 有没有人指着它;反过来,「下线」表单时是后端把 appNavNodeId 交还前端、由前端去删节点(FormDesignUnpublishRespVO:23),因为 infra 不能依赖 system。谁绕过这条链手工删节点,就会留下悬空绑定。

好消息是再发布时会检出:PublishDialog.vue:385-391 先 getNavNode(existingId),拿不到就抛一条带恢复步骤的错——

已登记的 App 导航节点 #361 不存在(可能被人工删除)。请先对这张表单执行「下线」,再重新发布以重建 App 入口

所以症状是「点重新发布报错」,不是「静默坏掉」。但它只在再发布时才触发,平时没有任何巡检会发现。批量自查:

sql
SELECT d.id, d.name, d.app_nav_node_id, n.id AS node_alive
FROM infra_form_design d LEFT JOIN system_nav_node n
  ON n.id = d.app_nav_node_id AND n.deleted = 0
WHERE d.app_nav_node_id IS NOT NULL AND d.deleted = 0;
-- node_alive 为 NULL 的行 = 悬空绑定;修法就是上面那句报错里的两步

接口清单 ​

@RequestMapping("/system/nav-node"),7 个端点。前缀 /admin-api。

方法路径权限消费方说明
GET/tree仅登录(无 @PreAuthorize)+ @DataPermission(enable=false)移动端按当前用户授权过滤后的树;terminal 缺省 app
GET/listsystem:nav-node:queryWeb 编辑器未过滤,返回全部节点(javadoc 明说「未做授权过滤」)
GET/get?id=system:nav-node:queryWeb 编辑器单节点详情
POST/createsystem:nav-node:createWeb 编辑器、表单发布返回新节点 id
PUT/updatesystem:nav-node:updateWeb 编辑器
PUT/update-sortsystem:nav-node:update无批量改排序/父节点,同一事务;当前无调用方
DELETE/delete?id=system:nav-node:deleteWeb 编辑器有子节点则拒(NAV_NODE_EXISTS_CHILDREN)
DELETE/delete-list?ids=system:nav-node:deleteWeb 编辑器批量删;「有子节点」判据排除同批选中的子节点(见下)

单删与批量删的校验口径故意不同(NavNodeServiceImpl:76-107)。批量删一整棵子树时,父和子会同时被勾上;若照单删的口径「有子节点就拒」,这个最常见的批量场景反而永远删不掉。真正要防的是留下 parentId 指向已删节点的孤儿,所以批量只拦「不在本次选中集合里」的子节点。注释写明这与 MenuServiceImpl#deleteMenuList 同口径。

NavNodeMapper#selectListByParentIds(:49-51)还有一条调用方约束:parentIds 必须非空——MyBatis-Plus 的 in() 收到空集合会拼出恒假条件,表现为「一个子节点都没有」,从而把整个孤儿校验静默跳过。

「发布到 App 端」是两步调用,不是一步:

POST /system/nav-node/create                              → 得到 navNodeId
PUT  /infra/form-design/bind-app-nav?designId&navNodeId   → 回写 infra_form_design.app_nav_node_id

为什么不在 infra 里一次做完:infra 模块不能依赖 system 模块(模块依赖是单向的),所以编排在前端 PublishDialog.vue(:358 建节点、:376 绑定)。取消发布走同一个 bind-app-nav,把 app_nav_node_id 置 NULL——后端用 LambdaUpdateWrapper#set 显式写 null,因为 MyBatis-Plus 默认的 NOT_NULL 更新策略会跳过 null 字段、置不掉。

怎么扩展 ​

给工作台加一个入口(最常见):Web 后台「App菜单」→ 在目标分组下新增 → node_type=3(导航项)、填 route(必须命中 pages.json 里已存在的路由,见下)、icon 用图标选择器选、按需填 permissions。移动端不需要发版,重进工作台或调 loadNavTree(true) 即可看到。

route 必须是已存在的路由,后端不校验

system_nav_node.route 只是一个字符串,后端没有任何机制验证它对应 pages.json 里的真实页面。填错的表现是:工作台能看到入口,点进去白屏或报「页面不存在」。

改完先核一遍:

bash
cd ainb-ui-admin-uniapp
node -e "const fs=require('fs');let s=fs.readFileSync('src/pages.json','utf8');
s=s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/^\s*\/\/.*$/gm,'');
const j=JSON.parse(s);const set=new Set(j.pages.map(p=>'/'+p.path));
j.subPackages.forEach(sp=>sp.pages.forEach(p=>set.add('/'+sp.root+'/'+p.path)));
console.log([...set].join('\n'))" | grep <你的 route>

(pages.json 是 JSONC,带 // GENERATED BY UNI-PAGES 注释,直接 require 会抛 SyntaxError。)

加一个新端(比如平板):① NavTerminalEnum 加枚举值;② 灌该 terminal 的种子数据;③ 新端调 /tree?terminal=xxx。不需要改剪枝逻辑、不需要改表结构——idx_terminal_parent 已经按 (terminal, parent_id) 建好了。

让某个入口只对部分角色可见:给节点填 permissions,填后端接口上真实存在的权限码(多个用逗号,任一命中即可见),然后走正常的角色授权流程把该权限码授给角色。⚠️ 别忘了这只是可见性——对应接口的 @PreAuthorize 才是真正的门。

把一个在线表单发布到 App 端:走 Web 侧表单设计器的「发布」对话框,见在线表单在移动端。

排查「某用户看不到某入口」,按这个顺序,四条能覆盖绝大多数情况:

  1. SELECT status, visible, permissions, deleted FROM system_nav_node WHERE id=? —— 是不是被停用/隐藏/软删了;
  2. 它是分组或子分组吗?是的话孩子全被过滤掉它也会消失(级联规则),去看子节点;
  3. permissions 非空 → 查该用户的实际权限集合(grant-enabled=true 时看 sys_resource 授权链,false 时看 role-menu),注意 sys_resource 的祖先链有进程级缓存且无 TTL,改过资源要重启后端;
  4. 移动端本地缓存:loadNavTree(false) 会直接用持久化的旧树,杀掉重进或强制刷新。

测试 ​

后端有测试,移动端没有。

NavNodeServiceImplTest(188 行,extends BaseDbUnitTest → H2,profile unit-test)共 8 个用例,全部打在剪枝逻辑上:

用例断言的行为
testGetNavTree_permissionFilter命中的项展示、未命中的隐藏
testGetNavTree_anyOfPermissionspermissions="app:x,app:z",用户只有 app:z 也展示(OR 语义)
testGetNavTree_emptyPermissionsAlwaysVisible空 permissions 不设门槛,授权集合为空也展示
testGetNavTree_invisibleCascadeHidesBranchvisible=false 级联隐藏整枝
testGetNavTree_disabledStatusCascadestatus 停用级联隐藏整枝
testGetNavTree_terminalIsolation不同 terminal 互不串树
testCreateNavNode创建
testDeleteNavNode_existsChildren有子节点时拒绝删除
bash
cd ainb && mvn -pl enlev-module-system test -Dtest=NavNodeServiceImplTest

没有测试覆盖的:resolveAllowedPermissions 的两条分支(它在 Controller 里,NavNodeServiceImplTest 打不到)、updateNavNodeSort、成环校验 isDescendantOf、以及移动端从 /tree 到渲染的整段映射。

移动端侧的验证只能靠手:改完节点 → 强刷工作台 → 看四种布局是否都正常。getMenuGroups 对「分组下直接挂叶子」的折叠(${id}__direct)是最容易在某个布局下渲染异常的地方。

坑与注意 ​

  • 🔴 count(*) 不带 deleted=0 会差 4.8 倍(97 vs 20)。裸 SQL 排查必加条件,走 Mapper 不用。
  • 🔴 /tree 和 /list 的过滤口径相反:前者按用户授权剪枝,后者返回全量。写脚本时拿错端点会得到完全不同的树,且都不报错。
  • 🔴 权限集合解析在两处刻意复刻(NavNodeController:156 与 AuthController#getPermissionInfo),没有编译期约束保证同步。改了一处忘了另一处,表现为「Web 端权限正常、移动端工作台少入口」或反之。
  • 🔴 删导航节点不会通知 infra_form_design,会留下悬空的 app_nav_node_id(实测已有一例:表单 2247 → 节点 361)。症状是 Web 显示「已发布到 App」但工作台找不到。
  • 停用分组 = 整枝消失,这是递归的副产品而不是显式级联,别去「补」一个级联判断。
  • 空分组也会被剪掉:一个启用且可见的分组,子项全被权限过滤后,它自己也不出现在 /tree 里。看到「分组没了」先查子节点权限,别先怀疑分组本身。
  • permissions 写错不报错,只会让所有人看不到。填之前先在 Web 菜单或资源目录里确认这个权限码真实存在。
  • route 后端不校验,填错的表现是点进去白屏。改完用上面的脚本核一遍 pages.json。
  • updateNavNodeSort 无调用方,拖拽排序未接线;sort 目前只能在「修改」弹窗里逐个改。
  • 导航树不分租户(@TenantIgnore)。所有租户共享同一棵树,租户间差异只能靠 permissions 表达。想按租户给不同工作台,现有模型做不到。
  • 移动端会缓存树:persist: true + loadNavTree(false) 命中缓存不发请求,且请求失败时保留旧树。后台改了节点,用户不重进/不强刷就看不到。
  • NavNodeTreeRespVO 故意不返回 permissions / status / visible(12 个字段里没有这三个)。前端拿不到门槛本身,所以不可能在前端做二次权限判断——这是设计意图,不要「顺手补上」这三个字段。
  • 单删与批量删的「有子节点」判据不同:批量会排除同批选中的子节点,否则删不掉整棵子树。改这段时别把两个口径「统一」掉。selectListByParentIds 收到空集合会让校验静默跳过,调用方须保证非空。
  • 移动端 MenuItem 上还留着 permission / permissions 两个字段(index.ts:28-29),注释写着「保留字段,服务端已过滤,前端不再使用」,toItem 从不填它们,而且 NavNodeTreeRespVO 根本不返回 permissions——填不进去。更麻烦的是后端 NavNodeServiceImpl:214 的 javadoc 声称 OR 语义「沿用移动端 MenuItem.permissions(index.ts:90-96)」,而那个行号现在是 toSubGroup/toGroup,语义也早已搬到服务端。这是一条跨仓过期引用:读后端注释去找前端依据会扑空。
  • 本地库被手工裁剪过(77 个墓碑来自界面操作,无迁移脚本)。跨机器对比工作台差异时,先确认两边的树是不是同一份数据。

→ 本节是贴着上下文的提醒;按症状查的移动端全域清单见移动端坑位,跨端清单见坑位索引。

未证实 ​

  • updateNavNodeSort 未接线是有意留待后续还是遗漏,代码里没有说明;后端注释写着「拖拽排序用」,但 Web 侧从来没有过拖拽交互的痕迹。
  • menu_ref_id 由发布流程写入、编辑器透传、后端零读取。它是为将来的「移动端入口 ↔ Web 菜单」双向跳转预留,还是纯粹给人看的审计线索,代码里没有说明。
  • NavTerminalEnum 只有 APP 一个值,terminal 字段和 idx_terminal_parent 索引却是为多端设计的。是否真的规划了其他端(pad / 大屏),仓库里没有依据。
  • 种子脚本声明的 82 行形状与本地存活的 20 行之间的裁剪标准(为什么留这 12 个导航项、删那 52 个)没有文档记录,只有 updater=1 和时间戳。
  • sys_resource 侧是否为这 20 个节点建了对应的授权资源、以及 grant-enabled=true 时权限码到资源目录的映射是否完整,本轮未查(需要后端在跑并逐个比对)。

相关页面 ​