若依权限与动态路由:GetInfo、GenerateRouters 链路解析
接手过几套基于若依的中后台之后我发现一个挺普遍的现象很多人能把登录跑通但一旦问起「登录之后那几个请求到底干了什么」回答就开始含糊了。若依里 GetInfo 负责拿角色和权限标识GenerateRouters 负责拿动态路由这两个接口看着简单实际上是整套权限体系的地基——前端能不能渲染出菜单、按钮能不能显示、接口能不能调通全压在它们身上。这篇就把这条链路从头到尾捋一遍包括后端菜单表怎么翻译成路由、前端怎么把后端返回的 component 字符串映射成真实组件、首页看板数据什么时候加载最合适以及我在实际项目里踩过的那些坑。不管你是刚拉下若依源码准备二次开发还是已经在维护一套跑了很久的系统应该都能从里面找到点有用的东西。1. 登录完成后的两次关键请求先把整条链路理清1.1 从点下登录按钮到看见首页中间发生了什么大多数人以为登录成功后就直接跳首页了其实中间隔了一层「准备工作」。完整顺序大概是这样用户提交账号密码后端校验通过后签发 token 返回前端把 token 存进 Cookie 或者 localStorage紧接着路由守卫拦下来发现本地还没有角色信息roles.length 0于是触发useUserStore().getInfo()拿到角色和权限之后再调用usePermissionStore().generateRoutes()把动态路由注册进 router 实例最后才next({ ...to, replace: true })放行到目标页面。这个顺序是有硬性依赖的不能乱。GetInfo 必须先于 GenerateRouters 完成因为后端在查菜单树的时候是拿当前登录用户的 userId 去过滤的而这个 userId 是从 token 解析出来的。如果你把两个请求并行发大概率不会出错但逻辑上仍然存在隐患——某些定制版本里 GenerateRouters 会读SysUser上的角色缓存并行的时候缓存还没写进去就会返回空路由。我见过一个项目为了「优化首屏速度」把两个请求合并成Promise.all结果偶发白屏排查了两天才定位到这儿。另外要说明一点getInfo和getRouters的命名在不同版本里不太一样。若依前后端分离单体版RuoYi-Vue里是GET /getInfo和GET /getRouters挂在SysLoginController上若是微服务版RuoYi-Cloud这两个接口通常落在 system 服务的登录控制器里由网关转发过去路径前缀可能会被 gateway 的 StripPrefix 过滤器裁掉一层。你在写自定义前端的时候接口地址一定要对着实际服务的 controller 注解看别照着网上文章抄。1.2 为什么若依要把权限和路由拆成两个接口这是很多人第一次读源码时会犯的疑惑既然菜单表里既有菜单又有按钮为什么不一次性全返回我的理解是职责分离 使用时机不同。GetInfo 返回的roles和permissions是能力清单是一堆扁平的字符串集合比如[system:user:list, system:user:add]。前端拿到之后主要干两件事一是v-hasPermi指令用来控制按钮显隐二是checkPermi/checkRole这类工具函数在需要的时候做判断。这些东西在整个会话期间是常驻内存的不会因为路由变化而变化。GenerateRouters 返回的是一棵结构化的树包含 path、name、component、meta 这些渲染所需的信息。它只在路由守卫首次进入时调用一次准确的说是每次刷新页面时调一次之后由前端自己维护。拆开的好处很直接权限标识可以在任意页面随时查不依赖路由是否已经注册完而路由树是重量级的嵌套结构只在必要的时候拉一次就够了。反过来说如果你把它们合成一个接口首页加载会被拖慢而且每次单独刷新权限比如后台改了角色权限要即时生效都得把整棵路由树重新拉一遍。1.3 前端状态放在哪里user store 和 permission store前端这一层若依把状态拆成了两个 storesrc/store/modules/user.jsVue3 版本是user.ts负责 token、name、avatar、roles、permissionssrc/store/modules/permission.ts负责 routes、addRoutes、defaultRoutes 以及侧边栏渲染用的 sidebarRouters。我建议二次开发时不要随意改这两个 store 的字段名因为v-hasPermi指令、Sidebar组件、TagsView组件、甚至路由守卫都直接引用了它们。改动一个字段名可能要全局搜十几个文件。真要扩展就在 store 里加字段别动已有的。还有个容易忽略的点permissions存的是Set转换后的数组超管被硬编码成[*:*:*]。前端v-hasPermi的实现里会先判断当前用户是否是 admin如果是就直接放行不做字符串匹配。所以你手动给某个角色配上*:*:*是没用的只有user.isAdmin()为真一般是 userId 1才有效。2. GetInfo 拆解角色和权限标识是怎么算出来的2.1 从 Controller 到 Service 的调用链后端这块逻辑不长但每一步都有讲究。控制器大致是这么几行GetMapping(getInfo) public AjaxResult getInfo() { SysUser user SecurityUtils.getLoginUser().getUser(); SetString roles permissionService.getRolePermission(user); SetString permissions permissionService.getMenuPermission(user); AjaxResult ajax AjaxResult.success(); ajax.put(user, user); ajax.put(roles, roles); ajax.put(permissions, permissions); return ajax; }注意SecurityUtils.getLoginUser()这一步它取的是登录时写进 Redis或 JWT里的缓存对象。这意味着如果管理员在后台改了用户的角色而这个用户还挂着没退登他看到的权限还是旧的。解决办法要么是让用户重新登录要么是在角色变更时主动清掉对应的登录缓存——若依在用户管理里改角色时确实会调用tokenService.delLoginUser之类的逻辑把在线用户踢下线但你自己写的业务表如果也涉及权限变更就得手动加上这一步。另外ajax.put(user, user)直接把SysUser实体丢出去了。如果你在SysUser上加了敏感字段比如身份证、手机号密文记得用JsonIgnore或者改造一个 VO 再返回否则前端 F12 一看全暴露了。2.2 角色集合 getRolePermission 的两种情况public SetString getRolePermission(SysUser user) { SetString roles new HashSetString(); if (user.isAdmin()) { roles.add(admin); } else { roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId())); } return roles; }逻辑很简单但有两个细节值得说。第一isAdmin()的判断依据通常是userId 1L。这是个硬编码约定很多项目部署完之后第一件事就是把超管账号改名换密码但 userId 依然是 1。如果你在初始化脚本里把超管的 userId 改成别的值那整套 admin 特权就失效了permissions会退化成普通角色的权限集合界面上什么按钮都点不了——这个坑我踩过一次当时在ry_2023xxxx.sql里手动调整了 insert 语句忘了SecurityUtils.isAdmin()的判断。第二返回的是角色的roleKey不是roleId。roleKey就是你在角色管理里看到的那个英文标识比如common、manager。前端hasRole(manager)就是拿它来比对的。所以改角色的 key 要慎重改完之后所有前端硬编码的hasRole判断都会失效。2.3 权限集合 getMenuPermission 与冒号拼接规则public SetString getMenuPermission(SysUser user) { SetString perms new HashSetString(); if (user.isAdmin()) { perms.add(*:*:*); } else { ListSysRole roles user.getRoles(); if (!StringUtils.isEmpty(roles)) { for (SysRole role : roles) { SetString rolePerms menuService.selectMenuPermsByRoleId(role.getRoleId()); role.setPermissions(rolePerms); perms.addAll(rolePerms); } } else { perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId())); } } return perms; }这里有个分叉如果用户对象上已经挂了角色列表就按角色逐个查权限没挂的话直接按 userId 查。实际运行时通常走的是第一个分支因为登录时会通过selectUserByUserName把roles一起装载进去。权限字符串的来源是sys_menu表的perms字段。菜单管理里给按钮类型menu_type F的菜单填上system:user:add这样的字符串就会出现在这里。多角色之间是并集关系不会取交集所以给用户加角色是「叠加权限」而不是「互相约束」。如果你需要「必须同时具备两个角色才能操作」这种逻辑得在前端或者后端自己额外判断。还有个细节role.setPermissions(rolePerms)这行不只是顺手写的它把每个角色的权限集合回填到SysRole对象上然后通过ajax.put(user, user)一起返回给前端。前端在用户管理或者角色详情里展示「这个角色有哪些权限」时用到的就是这份数据。提示perms字段填错一个冒号或者大小写权限就会静默失效。system:user:list和System:User:List是两个完全不同的字符串后端PreAuthorize(ss.hasPermi(system:user:list))是大小写敏感的精确匹配。2.4 前端拿到 roles 和 permissions 之后怎么用前端最常用的两个入口是v-hasPermi和v-hasRole指令它们在src/directive/permission目录下。// v-hasPermi 的核心逻辑 const all_permission *:*:*; const permissions useUserStore().permissions; if (value value instanceof Array value.length 0) { const permissionFlag value; const hasPermissions permissions.some(permission { return all_permission permission || permissionFlag.includes(permission); }); if (!hasPermissions) { el.parentNode el.parentNode.removeChild(el); } }注意最后一行不满足权限时是把 DOM 直接删掉不是display: none。这意味着你没法用「隐藏 后续放开」的方式做权限切换权限变更只能通过刷新页面或者重新走一遍 getInfo 生效。除了指令实际项目里我更常用的是工具函数形式因为有些场景需要动态判断import { checkPermi, checkRole } from /utils/permission; if (checkPermi([system:user:edit])) { // 做点什么 }这种写法在表格操作列里特别实用——你可以根据权限动态决定渲染哪几个按钮而不是写五个v-hasPermi然后靠指令去删 DOM。3. GenerateRouters 拆解菜单表到动态路由的翻译过程3.1 菜单表结构与 M/C/F 三种类型动态路由的一切源头都是sys_menu表。核心字段其实就几个menu_id、parent_id、menu_name、path、component、menu_type、visible、status、perms、icon、is_frame、is_cache、query。menu_type有三种取值这个必须记住类型值含义是否出现在路由树里说明M目录是通常对应 Layout 或 ParentView可以嵌套子菜单C菜单是对应一个真实的 .vue 页面F按钮否只贡献 perms 权限标识不参与路由很多新手会疑惑「为什么我加了个菜单但路由里看不到」八成是把menu_type选成了 F或者status填成了停用又或者visible设成了隐藏。隐藏的菜单其实是会进路由的只是meta上打了hidden: true的标记侧边栏不渲染而已——这一点很重要隐藏菜单可以用作详情页、编辑页这类需要路由但不希望出现在菜单栏的页面。还有一个字段is_frame0 表示是外链1 表示不是。设成外链时路由会走InnerLink组件用 iframe 嵌进去而不是走正常的组件加载流程。3.2 buildMenus 的四个分支决定路由长什么样后端SysMenuServiceImpl.buildMenus是整个翻译过程的核心。它遍历菜单树每个菜单根据情况落进四个分支之一。分支一目录类型且有子菜单。这时候会把alwaysShow设为 true、redirect设为noRedirect然后递归调用buildMenus处理 children。alwaysShow的作用是让侧边栏即使在只有一个子菜单时也保持父级展开的状态而不是把父级自动折叠掉。分支二一级菜单isMenuFrame。所谓一级菜单指的是parentId 0且类型为 C 的菜单——也就是直接在根目录下的页面。这种情况下后端会做一层「包装」外层路径设为/内层 children 里放真正的路径。为什么要这么绕因为若依的布局是「外层布局 内层页面」的结构如果直接给一级菜单挂组件它就脱离了 Layout 的壳子没有侧边栏和顶栏了。} else if (isMenuFrame(menu)) { router.setMeta(null); ListRouterVo childrenList new ArrayListRouterVo(); RouterVo children new RouterVo(); children.setPath(menu.getPath()); children.setComponent(menu.getComponent()); children.setName(StringUtils.capitalize(menu.getPath())); children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), ...)); children.setQuery(menu.getQuery()); childrenList.add(children); router.setChildren(childrenList); }分支三根节点下的内链。路径会被替换掉协议头组件设为InnerLink。分支四普通情况。什么特殊处理都不做直接用菜单自身的 path 和 component。同时getComponent方法会决定这个路由用哪个壳组件有 component 且不是一级菜单就用菜单里配的component 为空且是内链用InnerLinkcomponent 为空且父级不是 0用ParentView。这里的ParentView是个只渲染router-view的空壳专门用来承接多层嵌套。3.3 前端 filterAsyncRouter 与 component 字符串映射后端返回的component是个字符串比如system/user/index前端要把它变成真实的组件。这一步靠import.meta.glob完成const modules import.meta.glob(./../../views/**/*.vue) function loadView(view) { let res Object.keys(modules).forEach(key { if (key ./../../views/${view}.vue) { res modules[key] } }) return res }这段代码有两个必须知道的前提。第一import.meta.glob是构建时静态分析的路径里不能有变量。你写import.meta.glob(./../../views/**/*.vue)是合法的但写import.meta.glob(./../../views/ dir /**/*.vue)会直接报错或者返回空对象。第二路径必须一字不差。菜单里配system/user但实际文件在src/views/system/user/index.vue那loadView就会返回undefined路由匹配上了但组件是空的页面就白屏。这是最常见的白屏原因之一排查时先看浏览器控制台有没有组件加载相关的 warning。filterAsyncRouter负责把后端返回的路由数组递归转换function filterAsyncRouter(asyncRouterMap, lastRouter false, type false) { return asyncRouterMap.filter(route { if (type route.children) { route.children filterChildren(route.children) } if (route.component) { if (route.component Layout) { route.component Layout } else if (route.component ParentView) { route.component ParentView } else if (route.component InnerLink) { route.component InnerLink } else { route.component loadView(route.component) } } if (route.children ! null route.children.length) { route.children filterAsyncRouter(route.children, route, type) } else { delete route[children] delete route[redirect] } return true }) }有一行特别容易被忽略delete route[children]和delete route[redirect]。如果叶子节点上还挂着空的 children 数组Vue Router 会认为这是个有子路由的父级然后redirect又指向一个不存在的地方页面直接跳飞。这行删除操作就是为了避免这种「空壳父级」造成的诡异跳转。3.4 路由注册顺序与 404 兜底路由守卫里注册路由的写法是这样的usePermissionStore().generateRoutes().then(accessRoutes { accessRoutes.forEach(route { if (!isHttp(route.path)) { router.addRoute(route) } }) next({ ...to, replace: true }) })isHttp的判断是为了把外链路由排除掉外链不应该注册到前端 router 里。next({ ...to, replace: true })这一行是整套动态路由能跑通的关键。它的作用是取消当前这次导航用同样的目标重新触发一次。为什么要这样因为router.addRoute是同步的但当前这次导航已经开始匹配路由表了如果不用中文重新触发一次即便路由已经加进去了Vue Router 依然会认为目标路径不存在直接跳到 404。我第一次自己实现动态路由的时候就漏了这一行现象是「登录后能进首页但刷新任何子页面都跳 404」折腾了好久。还有 404 兜底规则的位置。若依把path: /:pathMatch(.*)*或者老版本的path: *放在constantRoutes的末尾。Vue Router 匹配时会按路径的具体程度打分具体路径优先级高于通配符所以一般情况下不会误伤。但如果你自己手写了一个通配符路由插在中间就要小心了——动态路由后注册可能永远匹配不到。注意如果用 Vue3 的router.addRoute加通配符兜底一定要确保它在所有动态路由都加完之后再加。否则页面刷新时兜底先匹配上了用户看到的永远是 404。4. 首页数据加载串行还是并行缓存放哪一层4.1 路由守卫里那行代码的真实作用前面提到next({ ...to, replace: true })这里再展开说说它跟首页加载的关系。replace: true的含义是不在浏览器历史里留记录避免用户点后退的时候又回到「正在加载」的中间态。而{ ...to }的展开写法也值得说。有人喜欢写成next(to)看着更简洁。但to是一个RouteLocationNormalized对象里面有一些只读属性直接传的话在某些 Vue Router 版本上会出现replace字段丢失或者matched数组不更新的问题。展开成新对象再传等于把当前的路由信息作为一个普通的 location 对象重新走一遍匹配流程这是最稳妥的写法。另外整个守卫是要配合 NProgress 的router.beforeEach((to, from, next) { NProgress.start() // ... 各种判断 }) router.afterEach(() { NProgress.done() })如果你在守卫里抛异常又没 catchNProgress.done()就不会执行进度条会一直卡在屏幕顶部。我见过不少项目的「页面卡住不跳转」其实就是这个原因看着像路由问题实际是个未捕获的异常。4.2 首页看板数据的加载策略首页src/views/index.vue通常是个数据看板要拉好几组统计数据。这里的关键问题是这些请求什么时候发有几种常见做法我挨个说下优劣。做法一放在路由守卫里。不推荐。守卫是全局的把业务数据塞进去会让它越来越臃肿而且每个人进任何页面都会触发一次首页数据请求纯属浪费。做法二放在首页组件的 mounted 里。最常见的做法。优点是无侵入、容易维护缺点是首页会经历「先渲染骨架再填数据」的过程如果接口慢用户会看到一段空白。做法三路由守卫完成后再发起。也就是在next()之后通过router.isReady()或者app.mount之后的时机去预热首页数据。这个做法可以让首页数据请求和路由渲染并行体验最好但代码组织上要单独抽一个模块。我一般的处理是「做法二 骨架屏」。在mounted里先给每个统计项赋默认值比如-或者0然后并行发请求回来了逐个替换。用Promise.allSettled而不是Promise.all很关键因为看板上往往有五六个接口只要一个挂了Promise.all就会整体 reject其他成功了的数据也显示不出来。async function loadDashboard() { const tasks [ fetchUserCount(), fetchOrderCount(), fetchRecentLogs(), fetchTodoList() ] const results await Promise.allSettled(tasks) results.forEach((r, i) { if (r.status fulfilled) { // 按索引填入对应模块 } else { console.warn(第 ${i} 个看板接口失败, r.reason) } }) }4.3 keep-alive 缓存与 tagsView 的 name 对齐首页加载还有一个绕不开的话题缓存。若依用了keep-alive来做页面缓存tagsView里的多标签页也是靠它维持状态的。但keep-alive有一个很硬性的要求——缓存组件的 name 必须和路由的 name 完全一致。而路由的 name 从哪来后端的getRouteName方法一般是对菜单的 path 做首字母大写处理。也就是说如果菜单 path 是user路由 name 就是User。那你的src/views/system/user/index.vue里就必须写export default { name: User }或者在script setup里用defineOptions({ name: User })。写错了会怎样页面能正常打开但切换标签页再回来页面状态被重置了用户填了一半的表单全没了。这种 bug 特别隐蔽因为功能上「没报错」。至于首页本身要不要缓存我的建议是不缓存。看板数据每次进来刷一遍是合理的而且很多项目里首页会放待办、通知这类时效性强的信息缓存反而会造成误导。你可以在菜单的is_cache字段里把它设成 0或者干脆在tagsView的右键菜单里让用户自己决定。5. 常见问题排查速查表与实操心得5.1 白屏、菜单不显示、刷新 404 这三类问题这三类问题我遇到的频率最高而且症状容易混淆先把它们的区分点列清楚。白屏通常是组件加载失败。打开控制台看如果有类似Component is missing template or render function或者一堆 undefined 的警告基本可以确定是loadView没匹配上。排查顺序是先在loadView里加一行console.log(key)看看 glob 到底扫到了哪些文件然后拿菜单表的 component 字段值和实际文件路径逐字符对比。常见的坑包括路径少了一层目录、文件名大小写不一致Linux 部署时尤其明显Windows 开发机不区分大小写所以本地好好的、以及后缀.vue重复或者缺失。菜单不显示要先分清是路由没生成还是侧边栏没渲染。F12 打开 Vue Devtools 看permissionstore 里的routes如果里面压根没这条路由说明是后端过滤掉了——检查菜单的status是不是停用当前用户有没有这个菜单关联的角色。如果路由在但侧边栏看不到那就是visible设成了 1或者meta.hidden为 true。刷新后 404几乎都是next({ ...to, replace: true })缺失或者写法不对。另一个可能是 Nginx 配置问题前端用了 history 模式但服务器没配try_files $uri $uri/ /index.html刷新非根路径就直接 404 了。这两个原因要分开看前者是浏览器里画面正常但路由匹配错了后者是服务器直接返回了 404 页面。5.2 权限标识不生效的几类原因按钮该显示的不显示、该隐藏的还在排查下来无非这么几种情况。第一种PreAuthorize注解里的标识和sys_menu表里填的不一致。后端拦截用的是注解里的字符串前端显隐用的是菜单表里的字符串两边得对上。我习惯的做法是在菜单管理里填完之后把整个perms字段复制出来贴到后端代码里避免手打出错。第二种用户登录后权限被缓存了。前面说过getInfo拿的是 Redis 里的登录对象改了角色要重新登录。如果你希望权限「准实时」生效可以在角色或者菜单变更的后端方法里主动清缓存。若依的用户管理里有「强退」功能本质就是调tokenService.delLoginUser。第三种多个角色权限取并集跟你预期不一致。比如用户同时有 A、B 两个角色A 有system:user:listB 没有那最终他就是有。反过来说你没法通过加一个「受限角色」的方式来限制权限只能靠不加权限来做限制。第四种超管的*:*:*覆盖了一切。测试的时候经常遇到「我用普通账号测权限怎么改都生效」最后一查发现测试账号是 userId 1。测试权限一定要用一个干净的普通账号。5.3 常见问题速查表现象最可能的原因快速验证方式登录后首页正常刷新子页面 404守卫里缺next({...to, replace: true})或 Nginx 未配 history 回退看 Network 是否请求了 index.html看守卫代码页面白屏无报错loadView未匹配到组件component 路径与文件不一致在loadView里打印 glob 的 key 列表菜单不出现菜单visible1、status0或角色未关联Vue Devtools 看 permission store 的 routes按钮权限不生效perms 字符串大小写/拼写错误或 Redis 权限缓存未刷新对比菜单表 perms 与注解字符串重新登录切换标签页后表单被重置组件 name 与路由 name 不一致keep-alive 失效检查组件name与菜单 path 的 capitalize 结果外链菜单打开是空白is_frame设置与实际需求不符检查该菜单的 is_frame 字段动态路由加上了但跳不过去404 兜底路由注册顺序靠前检查 constantRoutes 中通配符的位置微服务版接口 404网关 StripPrefix 配置与前端请求前缀不匹配对比网关配置与前端 baseURL5.4 几个踩坑之后固定下来的习惯最后分享几个我现在做若依二次开发时雷打不动的习惯都是从实际事故里总结出来的。第一菜单 component 字段一律从文件路径反推填写。先在src/views下把目录和文件建好再回菜单管理里照着填不要凭记忆写。填完之后随手在浏览器里点一下比事后排查白屏省事得多。第二每次改完菜单或者角色强制退出重新登录再测。不要相信「应该生效了」缓存这事说起来简单真出问题的时候特别耗时间。第三前端写权限判断优先用checkPermi函数而不是v-hasPermi指令。指令会直接删 DOM出问题时你连证据都看不到。函数形式可以在控制台打断点排查方便太多。第四Vue3 TS 项目里把路由相关的类型收一收。若依 Vue3 版本里filterAsyncRouter返回的类型经常对不上RouteRecordRaw报一堆类型错误。我的做法是在调用处显式标注as unknown as RouteRecordRaw[]而不是去改源码里的类型定义——改源码会让后续升级对比 diff 变得很痛苦。第五getInfo返回的 user 对象不要直接往业务里传。它带着password、salt之类的字段虽然一般是空串或者加密串但如果你的业务代码把整个 user 塞进日志或者响应里还是有泄露风险的。封装一个pickUser工具函数只取需要的字段。第六动态路由数量大的时候注意首屏耗时。菜单规模超过两三百个之后后端buildMenus的递归加上前端import.meta.glob的遍历会明显变慢。这时候可以考虑给getRouters加一层服务端缓存缓存 key 用 userId 角色版本号角色变更时清掉。不过这是优化阶段才需要考虑的事别一上来就搞容易把简单的逻辑搞复杂。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →