服务端驱动导航: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.java | 4 个 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 菜单 |
| Web | src/views/system/navNode/index.vue(458 行) | 「App菜单」列表页,用 el-table-v2 虚拟表格承载树 |
| Web | src/views/system/navNode/NavNodeForm.vue(340 行) | 新增/修改弹窗 |
| Web | src/views/system/navNode/{constants.ts,wotIcons.ts,WotIconSelect.vue} | 端标识与类型常量、wot-ui 图标名清单(351 行)、图标选择器 |
| Web | src/api/system/navNode/index.ts | 7 个函数(其中 updateNavNodeSort 无调用方) |
| Web | src/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 表结构:每端一棵自引用树
`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 递归。
递归里的判据只有两处:
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)三条,短路顺序固定:
status停用 → 不展示;visible == FALSE→ 不展示(null视为可见,与 DDL 默认b'1'一致);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 == null | Collections.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_menu | system_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菜单」界面手工软删的,不是脚本删的。
推论有三条,都别忽略:
- 不要用种子脚本的行数当「当前应该有多少节点」的判据,它描述的是灌完种子那一刻的形状;
- Web 编辑器
index.vue:381的注释「整棵树只有 82 行量级」同样已过期(4+14+64=82),存活量级是 20; - 手工软删发生在本地库,没有对应的迁移脚本——换一台机器导
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 入口
所以症状是「点重新发布报错」,不是「静默坏掉」。但它只在再发布时才触发,平时没有任何巡检会发现。批量自查:
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 | /list | system:nav-node:query | Web 编辑器 | 未过滤,返回全部节点(javadoc 明说「未做授权过滤」) |
| GET | /get?id= | system:nav-node:query | Web 编辑器 | 单节点详情 |
| POST | /create | system:nav-node:create | Web 编辑器、表单发布 | 返回新节点 id |
| PUT | /update | system:nav-node:update | Web 编辑器 | |
| PUT | /update-sort | system:nav-node:update | 无 | 批量改排序/父节点,同一事务;当前无调用方 |
| DELETE | /delete?id= | system:nav-node:delete | Web 编辑器 | 有子节点则拒(NAV_NODE_EXISTS_CHILDREN) |
| DELETE | /delete-list?ids= | system:nav-node:delete | Web 编辑器 | 批量删;「有子节点」判据排除同批选中的子节点(见下) |
单删与批量删的校验口径故意不同(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 里的真实页面。填错的表现是:工作台能看到入口,点进去白屏或报「页面不存在」。
改完先核一遍:
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 侧表单设计器的「发布」对话框,见在线表单在移动端。
排查「某用户看不到某入口」,按这个顺序,四条能覆盖绝大多数情况:
SELECT status, visible, permissions, deleted FROM system_nav_node WHERE id=?—— 是不是被停用/隐藏/软删了;- 它是分组或子分组吗?是的话孩子全被过滤掉它也会消失(级联规则),去看子节点;
permissions非空 → 查该用户的实际权限集合(grant-enabled=true时看sys_resource授权链,false时看 role-menu),注意sys_resource的祖先链有进程级缓存且无 TTL,改过资源要重启后端;- 移动端本地缓存:
loadNavTree(false)会直接用持久化的旧树,杀掉重进或强制刷新。
测试
后端有测试,移动端没有。
NavNodeServiceImplTest(188 行,extends BaseDbUnitTest → H2,profile unit-test)共 8 个用例,全部打在剪枝逻辑上:
| 用例 | 断言的行为 |
|---|---|
testGetNavTree_permissionFilter | 命中的项展示、未命中的隐藏 |
testGetNavTree_anyOfPermissions | permissions="app:x,app:z",用户只有 app:z 也展示(OR 语义) |
testGetNavTree_emptyPermissionsAlwaysVisible | 空 permissions 不设门槛,授权集合为空也展示 |
testGetNavTree_invisibleCascadeHidesBranch | visible=false 级联隐藏整枝 |
testGetNavTree_disabledStatusCascade | status 停用级联隐藏整枝 |
testGetNavTree_terminalIsolation | 不同 terminal 互不串树 |
testCreateNavNode | 创建 |
testDeleteNavNode_existsChildren | 有子节点时拒绝删除 |
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时权限码到资源目录的映射是否完整,本轮未查(需要后端在跑并逐个比对)。
相关页面
- 移动端总览 —— 三仓关系、已交付与未交付分包
- 请求封装与登录态 ——
/tree走的那条 http 链路、401 无感刷新、租户头 - 在线表单在移动端 ——
app_nav_node_id与「发布到 App 端」的另一半 - 工程结构与约定式路由 ——
route必须命中的pages.json是怎么生成的 - 移动端页面开发范式 —— 工作台点进去之后那些页面怎么写
- 权限体系总览 ——
grant-enabled两种模式与授权引擎 - 诊断与排错 —— 「权限没生效」的症状 → 根因总表
- system 域开发 ——
system_menu/sys_resource那套 Web 侧菜单与授权 - 移动端坑位 —— 本端专属的坑(M5–M7 是导航这一类)
- 坑位索引 —— 按症状查的跨端清单
