服务端动态菜单接口设计与RBAC权限控制实践

发布时间:2026/10/31 3:01:34
服务端动态菜单接口设计与RBAC权限控制实践

服务端菜单接口这个题目听起来平平无奇,但凡真正在后台系统里踩过路由和权限坑的人都知道,它往往是整个管理端最先动手、也最容易返工的一块。前端要拿它渲染侧边栏,路由要拿它做动态注册,按钮级的操作权限也要从它身上取,甚至不同角色登录后看到的菜单结构完全不一样——这几件事全压在同一个接口上。我接触过的几个项目里,这一块的返工次数几乎是最多的:第一版拍脑袋用递归查库,菜单一多就慢;第二版全量返回不区分按钮,前端拿到一堆看不见的节点;第三版把权限标识符和路由地址混在一起,导致换个页面路径就要改数据库。这篇文章就把服务端菜单接口从数据模型、接口契约、权限绑定、缓存策略到线上排查完整拆一遍,适合正在做后台管理系统的后端同学,也适合需要和前面对接的前端同学看。文中会给出可直接落地的表结构、查询逻辑和树形组装代码,以及几个只有真正跑过线上才会遇到的问题。

1. 先想清楚服务端菜单接口到底在解决什么问题

动手写代码之前,值得先花十分钟把"菜单接口"的边界划清楚。很多团队一开始的假设是"菜单就是导航栏那几行链接",于是设计成一张只有 id、name、url 的表,等到要做权限、要做多端、要做按钮控制的时候,才发现需要推倒重来。我自己踩过这个循环两次,第二次之后才把思路理清楚:菜单接口本质上是在描述一棵可授权的界面结构树,它同时承担了三件事——告诉前端有哪些页面、告诉前端这些页面怎么加载、告诉后端当前用户能不能做某个操作。

1.1 从静态路由表到动态菜单的迁移逻辑

最早的做法是把路由写死在前端代码里,配一份router.config.js,谁能进哪个页面靠路由守卫里的角色判断。这种方案在角色固定的场景下确实够用,代码直观,改动成本低。问题出在角色数量上去之后:每新增一个角色、每调整一次可见范围,都要改前端代码重新发版。业务方一句"这个角色不该看到财务模块",就得走一次完整的发布流程,这在迭代节奏快的项目里很难接受。

改成服务端下发之后,前端只保留一份"组件映射表",路径到组件的对应关系还在前端,但"有哪些路径、什么顺序、叫什么名字"由接口决定。这个拆分很关键:前端负责"怎么渲染",后端负责"渲染什么"。这样做的好处是角色调整只改数据,不用发版;代价是后端必须保证这份数据在任何时候都是一棵合法、完整、可渲染的树,一旦出现孤儿节点或者循环引用,整个侧边栏直接白屏。

取舍建议:如果角色不超过三个、两年内不打算做多租户,静态路由完全够用,别为了架构感强行上动态菜单。反过来,只要出现"不同客户看到的功能不一样"的需求,越早改成本越低。

1.2 一套菜单数据要同时服务几个消费方

这是设计时最容易忽略的一点。同一个接口的返回,实际有三个消费方在等着用:

  • 侧边栏组件:只要目录和页面节点,要顺序、要图标、要显示名称,不要按钮。
  • 路由注册逻辑:要路径、要组件标识、要是否缓存页面、要重定向目标,不要按钮。
  • 按钮权限判断:只要权限标识符的集合,挂到全局状态里供页面内的v-if或指令使用,完全不关心树形结构。

如果一份数据原样丢给三方,前端就得自己做三遍过滤,而且容易过滤漏。我在项目里更倾向的做法是:接口主返回树形结构(供前两者共用),同时附加一个扁平的权限标识符数组(供第三方使用)。这样前端不需要遍历树去收集按钮标识,省掉一层容易出错的逻辑。多返回一个数组带来的体积增量很小,换来的却是调用方逻辑的简化,这笔账很划算。

1.3 不该塞进菜单接口的东西

有几个东西经常被顺手塞进来,后来都变成了维护负担。第一个是用户个人信息,有些实现会把昵称、头像一起返回,理由是"反正登录后都要用",结果菜单接口被频繁调用,用户信息却很少变,白白增加查询压力。第二个是字典数据,比如状态枚举的显示文本,这类数据应该走独立的字典接口并做长缓存,和菜单的生命周期完全不同。第三个是实时未读数,这属于高频变化的业务数据,混进来会让菜单接口的缓存策略彻底失效。

判断标准其实很简单:这份数据的变化频率是否和菜单结构一致。一致就放进来,不一致就拆出去。菜单结构通常一周变不了一次,而用户信息、未读数可能每分钟都在变,把两类数据绑在同一个缓存键上,只能二选一,要么缓存太短没效果,要么数据陈旧被投诉。

2. 数据模型与接口契约设计

模型设计的核心决策是"用一张表还是多张表"。见过拆成菜单表、按钮表、路由表三张的,也见过全部塞进一张的。我的结论是:目录、页面、按钮用同一张表,靠类型字段区分。原因在于它们共享父子关系、共享排序、共享可见性控制,拆表之后每次查树都要做三次查询再三路合并,代码量和出错概率都上升,收益却只是"表看起来更干净"。

2.1 表结构设计:一张表扛住目录、页面、按钮

下面这份表结构是经过几个项目打磨后比较稳定的版本,字段不多,但每个都有明确用途:

CREATE TABLE sys_menu ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父节点ID,0为根', menu_name VARCHAR(64) NOT NULL COMMENT '显示名称', menu_type TINYINT NOT NULL COMMENT '1目录 2页面 3按钮', route_path VARCHAR(200) DEFAULT NULL COMMENT '路由地址,按钮为空', component VARCHAR(200) DEFAULT NULL COMMENT '前端组件标识,目录为空', perms VARCHAR(120) DEFAULT NULL COMMENT '权限标识符,如 sys:user:add', icon VARCHAR(64) DEFAULT NULL COMMENT '图标标识', sort_no INT NOT NULL DEFAULT 0 COMMENT '同级排序,小的在前', visible TINYINT NOT NULL DEFAULT 1 COMMENT '是否在侧边栏显示 1是 0否', keep_alive TINYINT NOT NULL DEFAULT 0 COMMENT '页面是否缓存 1是 0否', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_parent (parent_id), KEY idx_perms (perms) ) COMMENT '菜单与权限表';

几个字段值得单独说明。menu_type用整型而不是字符串,是为了在 SQL 里做范围过滤时走索引更自然,同时也避免大小写不一致带来的麻烦。visible和status看似重复,实际含义完全不同:visible=0表示这个页面存在、可以访问、但不显示在侧边栏(典型场景是详情页、编辑页这类只能从列表跳转的页面);status=0表示整个节点被停用,连访问都不允许。把这两个概念混成一个字段,后期一定会出问题。

route_path建议存相对路径(如/system/user),而不是完整 URL。完整 URL 一旦换了域名或者加了网关前缀,全表数据都要刷一遍。同理,component存的是前端约定的组件标识字符串(如system/user/index),后端不需要知道这个文件在哪,只要原样透传,前端自己做映射。

2.2 字段取舍:哪些字段必须有,哪些是坑

有人会想加一个level字段存层级深度,方便查询。我不建议这么做,因为它是冗余数据,一旦某个节点被移动到别的父节点下,所有子孙的 level 都要跟着更新,漏更新一次就会出现层级错乱。层级在需要时由代码计算,成本很低。

也有人会加full_path存完整路径链,用于前端拼接面包屑。这个字段的维护成本和 level 一样高,同样不推荐。面包屑完全可以在前端根据树的父子关系推导出来,没必要在数据库里落一份随时可能失效的副本。

反过来,有两个字段经常被漏掉但很重要。一个是keep_alive,前端做页面缓存时要用,没有这个字段就只能在前端硬编码哪些页面要缓存。另一个是软删除标记del_flag,菜单被删除后如果有历史操作日志引用了它,硬删会导致日志查不到名称。加一个标记位,查询时统一带上del_flag = 0,成本极低但省事。

2.3 接口返回结构:树形还是扁平

这是前后端最容易扯皮的地方。扁平结构传输体积小、前端处理灵活,但每个消费方都要自己写一遍组装逻辑;树形结构开箱即用,但传输时会有冗余(每个节点都要包一层 children)。

我的做法是主接口返回树,同时提供一个可选的扁平查询接口给需要做下拉选择的场景。树形结构的字段大致如下:

{ "id": 100, "parentId": 0, "name": "系统管理", "type": 1, "path": "/system", "component": null, "icon": "setting", "children": [ { "id": 101, "parentId": 100, "name": "用户管理", "type": 2, "path": "/system/user", "component": "system/user/index", "perms": "sys:user:list", "keepAlive": true, "children": [] } ] }

注意children在叶子节点上返回空数组而不是null。这个细节看着无所谓,但前端组件递归渲染时,null和[]的处理路径不同,前者容易触发判空报错。统一返回空数组,能省掉一堆if (node.children && node.children.length)的防御代码。

另外,按钮节点不要出现在树里。按钮没有路径、没有组件,出现在树里会让前端的递归渲染多一层无意义的判断。正确做法是在组装树之前就把menu_type = 3的节点过滤掉,它们的perms单独收集成一个数组返回。

2.4 排序与稳定性

排序这件事,坑往往出在"两个节点sort_no相同"的时候。数据库在没有明确排序字段的情况下,返回顺序是不保证的——今天查出来 A 在前,明天可能就变成 B 在前。用户看到的现象是"菜单顺序偶尔会自己变",非常诡异且难以复现。

解决办法很简单:排序时永远带一个唯一键兜底,写成ORDER BY sort_no ASC, id ASC。这样即使sort_no全部相同,顺序也是确定的。我在三个项目里都遇到过这个问题,每次都是加了 id 兜底就消失了。

还有一点,sort_no用整数递增(10、20、30)比连续整数(1、2、3)更好,中间插入新节点时不用整体重排,取前后两个值的平均数即可。当然如果频繁插入导致精度不够,还是得走一次批量重排,这种情况一般很少见。

3. 权限体系与菜单的绑定方式

菜单接口和权限体系是绑死的,绕不开。这里最核心的设计决策是:权限标识符和路由地址必须解耦。见过不少实现直接拿路由地址当权限标识(如/system/user),结果就是页面路径一调整,所有角色授权记录全部失效,还得挨个修数据。

3.1 菜单权限的三种粒度

实际项目里通常需要三层控制,缺一层都会别扭:

粒度控制对象数据来源典型实现
模块级整个功能模块是否可见目录节点是否授权侧边栏不渲染该目录
页面级某个页面能否进入页面节点是否授权路由守卫拦截 + 接口鉴权
操作级页面内某个按钮能否点击按钮节点的 perms前端指令控制 + 后端注解校验

三层里最容易做漏的是页面级。很多团队只做了模块级和操作级,觉得"菜单都不显示了,用户自然进不去"。这个假设是错的:用户完全可以直接在地址栏敲路径,或者从历史记录里点进去。如果后端接口没有对应的鉴权,页面虽然白屏但数据接口能调通,等于没有防护。

所以页面级的正确做法是双保险:前端路由守卫拦住无权限的路径跳转,后端每个业务接口再用权限标识符校验一次。前端拦的是体验,后端拦的是安全,缺一不可。

3.2 权限标识符的命名规范

perms字段的命名,我推荐用三段式:模块:实体:动作。比如sys:user:add、sys:user:edit、sys:role:assign。三段式的优点是能按前缀做批量匹配,未来要做"某模块全部权限"这种快捷授权时,LIKE 'sys:user:%'就能覆盖,不用维护额外的映射表。

动作词也要统一,常见的一套是list / query / add / edit / remove / export / import / assign / audit。最怕的是同一个意思用了好几个词,add和create混着来,delete和remove混着来,时间一长没人记得清某个模块到底该用哪个。

踩坑提示:权限标识符一旦上线就当作契约对待,宁可新增不要改名。改名意味着所有已授权的角色记录要同步更新,而且历史操作日志里的标识符会对不上。如果确实要调整,用新增 + 旧标识符保留兼容的方式过渡。

3.3 角色-菜单-用户链路与鉴权时序

完整的链路是三张表:用户表、角色表、菜单表,中间靠用户角色关联表和角色菜单关联表连起来。查询某个用户的菜单时,SQL 大致是这样的结构:

SELECT DISTINCT m.* FROM sys_menu m JOIN sys_role_menu rm ON rm.menu_id = m.id JOIN sys_user_role ur ON ur.role_id = rm.role_id WHERE ur.user_id = #{userId} AND m.status = 1 AND m.del_flag = 0 ORDER BY m.sort_no ASC, m.id ASC;

这里用DISTINCT是因为一个用户可能有多个角色,多个角色可能都授权了同一个菜单,不去重会出现重复节点,组装树的时候同一个 id 被塞进 children 两次,前端渲染出两个一模一样的菜单项。

时序上要注意的是:菜单查询必须在用户身份确定之后。听起来是废话,但在网关或者拦截器里做统一鉴权的架构下,容易出现"菜单接口被放行"的配置疏漏——因为登录页之后马上要调菜单接口,有同学图省事把/menu/**加进了白名单,结果未登录也能拿到全量菜单。这个问题我在做安全评审时见过不止一次,检查方法很简单:拿一个无效 token 直接请求菜单接口,看返回是 401 还是数据。

4. 实操:从建表到跑通接口

理论说完,进入能直接抄的部分。这一节按实际开发顺序走:建表 → 造数据 → 写查询 → 组装树 → 挂缓存 → 联调。代码用 Java + Spring Boot + MyBatis 的写法,换其他语言逻辑完全一致。

4.1 建表与初始化数据

表结构在 2.1 已经给过,这里补充一段初始化数据,能直接跑出一个三层结构:

INSERT INTO sys_menu (id, parent_id, menu_name, menu_type, route_path, component, perms, icon, sort_no) VALUES (100, 0, '系统管理', 1, '/system', NULL, NULL, 'setting', 10), (101, 100, '用户管理', 2, '/system/user', 'system/user/index', 'sys:user:list', 'user', 10), (102, 101, '新增用户', 3, NULL, NULL, 'sys:user:add', NULL, 10), (103, 101, '编辑用户', 3, NULL, NULL, 'sys:user:edit', NULL, 20), (104, 101, '删除用户', 3, NULL, NULL, 'sys:user:remove',NULL, 30), (200, 0, '内容管理', 1, '/content', NULL, NULL, 'doc', 20), (201, 200, '文章列表', 2, '/content/article','content/article/index','cms:article:list',NULL, 10);

建完表要做的第一件事是检查数据合法性,而不是急着写接口。常见的脏数据有三类:parent_id指向一个不存在的 id(孤儿节点)、parent_id指向自己、以及 A 的父是 B、B 的父又是 A(循环引用)。前两类在组装树时会静默丢失节点,第三类会直接把递归打成死循环。可以先用一条 SQL 把孤儿节点捞出来:

SELECT c.id, c.menu_name, c.parent_id FROM sys_menu c LEFT JOIN sys_menu p ON p.id = c.parent_id WHERE c.parent_id <> 0 AND p.id IS NULL AND c.del_flag = 0;

有返回结果就说明存在孤儿节点,必须先修数据再上线,否则前端会莫名其妙少几个菜单,排查起来很费时间。

4.2 查询与树形组装

树形组装的实现方式直接决定性能。绝对不要在递归里查数据库——那种写法在菜单只有二十条时看不出问题,一旦某个客户配了两百个节点,一次接口调用打出两百次 SQL,慢查询日志立刻爆掉。

正确做法是一次查出该用户有权限的全部节点,在内存里组装:

public List<MenuNode> buildTree(Long userId) { // 1. 一次查询拿到扁平列表,按钮节点也一并取出,后面单独用 List<MenuNode> all = menuMapper.selectByUserId(userId); if (all.isEmpty()) { return Collections.emptyList(); } // 2. 按 parentId 分组,避免嵌套遍历 Map<Long, List<MenuNode>> group = all.stream() .collect(Collectors.groupingBy(MenuNode::getParentId)); // 3. 只对非按钮节点建树 Set<Long> buttonIds = all.stream() .filter(n -> n.getType() == 3) .map(MenuNode::getId) .collect(Collectors.toSet()); List<MenuNode> roots = group.getOrDefault(0L, Collections.emptyList()); Deque<MenuNode> stack = new ArrayDeque<>(roots); List<MenuNode> result = new ArrayList<>(); // 4. 用栈做广度遍历,同时剔除按钮节点 while (!stack.isEmpty()) { MenuNode node = stack.poll(); if (buttonIds.contains(node.getId())) { continue; } List<MenuNode> children = group.getOrDefault(node.getId(), Collections.emptyList()); children.sort(Comparator.comparing(MenuNode::getSortNo) .thenComparing(MenuNode::getId)); node.setChildren(children); stack.addAll(children); if (node.getParentId() == 0L) { result.add(node); } } return result; }

这段代码有几个细节值得注意。第一,分组之后每个节点的 children 是同一批对象引用,不需要再重新 new 一遍,内存开销小。第二,排序放在组装过程中做,保证每个层级的顺序都是确定的。第三,用队列而不是递归,好处是不受调用栈深度限制,即使有人把菜单配成一百层也不会栈溢出——当然这种情况本身就该在数据层面拦掉。

至于权限标识符的收集,就简单了:

List<String> perms = all.stream() .filter(n -> n.getType() == 3 && StringUtils.hasText(n.getPerms())) .map(MenuNode::getPerms) .distinct() .collect(Collectors.toList());

4.3 挂缓存与缓存失效策略

菜单数据的特点是读极多、写极少。一个两百人的后台系统,每人每天登录一次、刷新若干次,菜单接口的调用量大概是几千次,而菜单表可能一个月才改一次。这种读多写少的场景,缓存收益非常明显,不加缓存基本等于浪费数据库连接。

缓存键的设计是关键。最粗暴的做法是按用户 ID 做键,问题是同一角色的几十个用户会各自缓存一份完全相同的树,内存浪费严重。更合理的做法是按角色组合做键:把用户的角色 id 排序后拼成一个字符串,同一个角色组合的用户共享同一份缓存。

String roleKey = roleIds.stream().sorted() .map(String::valueOf) .collect(Collectors.joining(",")); String cacheKey = "menu:tree:" + DigestUtils.md5Hex(roleKey);

用 MD5 是因为角色多的时候拼接串会很长,做键不美观,而且有些缓存中间件对键长度有限制。哈希之后长度固定,冲突概率可以忽略。

失效策略我推荐版本号方案而不是逐个删键。维护一个全局版本号,缓存的键里带上这个版本号,菜单数据一有变更就把版本号自增,旧键自然失效,等着过期回收:

// 读取 Long version = cacheClient.get("menu:version"); String key = "menu:tree:v" + version + ":" + hash; // 写入菜单后 cacheClient.incr("menu:version");

这个方案的好处是变更时不需要知道有哪些缓存键,也不用担心漏删。代价是会残留一批旧版本的键,需要给它们设一个合理的过期时间(比如两小时)让它们自然淘汰。相比逐个删键的方案,我更信任这个——因为漏删导致的数据不一致,用户投诉起来非常难定位。

注意:缓存里也要防穿透。如果某个角色确实没有任何菜单,缓存里存一个空列表标记,别存 null,否则每次请求都会打到数据库。空列表再加一个较短过期时间(比如五分钟),既能挡住穿透,又不会让新授权的角色等太久。

4.4 与前端联调的约定

联调阶段最容易出问题的地方不是接口本身,而是字段含义没对齐。我习惯在接口文档里明确写清楚几条约定,能省掉大量来回沟通:

  • path一律返回以/开头的绝对路径,前端不做拼接。
  • 目录节点的component返回null,前端遇到null就知道这是纯目录,不注册路由。
  • visible = 0的页面仍然要返回,前端负责注册路由但不渲染到侧边栏。
  • children永远返回数组,不返回null。
  • 权限标识符单独放在perms数组里,不在树里重复。

还有一条经验:联调阶段先造一棵"极端树"。包含一个没有子节点的空目录、一个三层深度的嵌套、一个visible = 0的页面、一个只有按钮没有页面的情况。把这几种情况一次性测完,比上线后逐个发现要省心得多。我现在的习惯是让前端同学用这棵测试树做开发,很多边界问题在开发阶段就暴露了。

5. 常见问题与排查实录

下面这些是我在几个项目里真实遇到过的问题,按出现频率排序。每一个都附上了排查思路,遇到类似现象可以直接对照。

5.1 树形结构的几个典型坑

现象一:侧边栏少了几项,但数据库里数据都在。八成是孤儿节点——某个节点的parent_id指向了一个已被删除或者从未存在的 id。因为组装树是从parent_id = 0开始往下找的,找不到父节点的那些节点永远不会被遍历到,静默消失。用 4.1 里那条 SQL 一查就知道。

现象二:接口直接超时或者 CPU 打满。检查是否存在循环引用。A 的父是 B、B 的父是 A,两个节点互为父子,从根节点出发永远走不到它们,但如果组装逻辑是从全量数据出发遍历,就会无限循环。防御手段有两个:一是在组装时记录已访问的 id 集合,遇到重复直接跳过并打日志;二是在保存菜单时做一次环路检测,从当前节点往上追父节点,如果追到了自己就拒绝保存。

现象三:同一个菜单显示了两遍。用户的多个角色都授权了同一个菜单,SQL 没去重。这个在 3.3 提过,加DISTINCT或者用GROUP BY m.id都能解决。但更隐蔽的情况是 SQL 去了重,内存组装时又把同一批对象塞了两次——比如分组用的 key 类型不一致,Long和Integer混用导致分组错了。统一用Long能避免这类问题。

现象四:菜单顺序和配置的不一致。见 2.4,排序没带唯一键兜底。还有一种可能是前端自己又排了一次序,把后端的顺序覆盖了。这个要和前端确认清楚,别两边都排。

5.2 缓存与数据不一致

缓存相关的问题症状都很像:改了菜单,用户刷新看不到变化。排查时按这个顺序走:

  1. 先确认数据库是否真的改了,别一上来就怀疑缓存。
  2. 检查版本号有没有自增。日志里打一行版本号变更记录,一目了然。
  3. 检查缓存过期时间是不是设得太长,比如设了二十四小时,那改了之后确实要等一天。
  4. 检查是否存在多实例部署但版本号存在本地内存里——这种在多节点下必然不一致,版本号必须放在共享存储里。

还有一种情况是用户权限变了但菜单缓存没变。比如给某个角色新增了菜单,用户的角色组合没变,缓存键没变,读到的还是旧的。这时候只 bump 菜单版本号是不够的,还要考虑角色-菜单关系变更时也 bump。我的做法是把所有会影响菜单结果的写操作统一收敛到一个"菜单变更事件"里,不管是改菜单表、改角色菜单关系、还是改用户角色关系,都触发同一个事件去自增版本号。这样逻辑集中,不容易漏。

排查小技巧:在菜单接口的返回里加一个不显眼的_v字段带出版本号,联调时一眼就能看出这次请求命中的是哪一版缓存。

5.3 权限越权与数据泄露

这是菜单接口里风险最高的一类问题,而且一旦出问题就是安全事故级别。几个必须检查的点:

未登录能否拿到菜单。前面提过,测试方法是拿无效 token 直接请求。注意有些框架的拦截器配置是"排除路径",路径写错一个字符就变成全部放行了,务必实测。

能否通过改参数拿到别人的菜单。如果接口设计成GET /menu?userId=123,那就是典型的水平越权——用户改成别人的 id 就能拿到对方的菜单结构。正确做法是从当前登录上下文里取用户 id,接口不接受外部传入的用户参数。

停用的菜单是否还能被访问。status = 0的节点应该从查询里直接过滤掉,覆盖到所有子孙节点。见过只过滤了父节点没过滤子节点的实现,结果是一个停用的目录下挂着的页面仍然能被直接访问。

返回数据里是否带出了不该出现的字段。比如菜单表里有remark或者内部备注字段,直接SELECT *就全返回给前端了。用明确的字段列表,或者用 DTO 转换,别图省事。

5.4 问题速查表

把上面的内容整理成一张表,出现问题的时候直接对照:

现象最可能原因排查动作
侧边栏少节点孤儿节点或父节点被停用查parent_id是否存在
接口超时循环引用或递归查库查环路 + 检查是否在循环里查 SQL
菜单重复多角色授权未去重SQL 加DISTINCT
顺序乱跳排序缺唯一键ORDER BY sort_no, id
改了不生效缓存版本未更新看版本号日志
按钮显示错乱按钮节点混进树里组装前按type过滤
未登录能拿到数据拦截器白名单配错用无效 token 实测
详情页进不去visible = 0被误过滤只过滤显示,不过滤返回

这张表我一般直接贴在项目文档里,新人接手时能少问一半问题。

6. 性能、扩展与后续演进

菜单接口本身的复杂度不高,但如果系统规模上去,或者业务形态变化,还是有几个方向需要提前留好口子。

6.1 大菜单量下的性能表现

一般来说,一个管理系统的菜单节点总数在一百到三百之间,一次全量查询加上内存组装,耗时在几毫秒到十几毫秒,完全不需要担心。真正需要关注的是两种情况:一是多租户场景下,租户数量多、每个租户的菜单都不同,缓存命中率会下降;二是菜单节点超过一千个,单次返回的 JSON 体积变大,网络传输和前端解析开始有感知。

第一种情况的解法是按租户维度拆分缓存键,保证每个租户内部共享。第二种情况可以考虑按需加载:首屏只返回前两级,深层节点在用户展开时再请求。不过说实话,菜单超过一千个的系统,本身的信息架构就该重新设计了,技术手段只是缓解。

还有一个容易忽略的性能点是索引。parent_id和perms上必须建索引,用户角色关联表上的user_id、role_id也要建。这几张表的查询都是典型的等值匹配,没索引在数据量上去之后会明显变慢。

6.2 多端差异与国际化

同一个后端,可能同时服务 PC 端、移动端、还有大屏。不同端需要的菜单结构往往不一样:PC 端要完整的三级导航,移动端可能只要一级入口,大屏可能完全不需要菜单。

处理方式有两种。一种是加一个client_type字段,同一个逻辑菜单在不同端配置不同的记录,查询时按端过滤。这种方式数据冗余但逻辑清晰,各个端互不干扰。另一种是只存一份,在接口层做裁剪,移动端截断到一级。前者的好处是各端可以独立配置名称和排序,比如移动端把"用户管理"简化成"用户",后者的好处是数据量小。

我倾向第一种,因为不同端的文案差异通常不只是"要不要显示",而是真的不一样。至于国际化,menu_name单存一份中文肯定不够,常见做法是存一个 key,前端根据当前语言去翻译;或者单独建一张菜单多语言表,按menu_id + lang存名称。前者简单但把翻译工作推给了前端,后者复杂但数据自洽。中小项目用前者足够。

6.3 接口版本化与灰度

菜单结构变更时的风险在于:后端加了新菜单,前端还没有对应的组件,用户点进去就是白屏。这类问题在前后端分离、独立发布的架构下很常见。

缓解办法是前端对未知组件做兜底。拿到component字符串后如果映射表里找不到,注册一个占位页面而不是直接抛错,这样至少不会白屏。后端这边,可以给菜单加一个min_client_version字段,低于某个版本的前端请求时不返回这批新菜单,等前端发版后再放开。这个方案听着有点重,但在发布节奏不一致的团队里确实好用。

接口本身的版本化,我不建议给菜单接口加v1、v2这种路径版本。菜单的结构相对稳定,真要改字段,用新增字段 + 保留旧字段兼容的方式过渡更平滑。路径版本化会让前端不得不维护两套调用逻辑,收益不大。

最后分享一个我在实际操作中的习惯:每次改菜单相关的代码,先跑一遍"空数据"和"只有按钮"这两个极端用例。空数据能验证接口不会因为空列表报错,只有按钮能验证过滤逻辑是否正确。这两个用例加起来不到五分钟,但拦住过我好几次上线事故。菜单这东西平时安安静静,出问题时往往是在客户演示的前一分钟,多花五分钟测边界,比事后救火划算得多。