SpringBoot+MyBatis+Vue RBAC权限管理系统实战:从数据模型到动态路由

发布时间:2026/11/5 2:57:30
SpringBoot+MyBatis+Vue RBAC权限管理系统实战:从数据模型到动态路由 简介这份资源是一套基于 SpringBoot、MyBatis 与 Vue.js 构建的权限管理系统完整工程面向具备一定 Java Web 基础、希望深入理解前后端分离权限控制实现的学习者与开发者可用于课程设计、毕业项目参考或后台系统二次开发。压缩包共 312 个文件约 292.22MB以 187 个 XML 映射与配置、38 个 Java 源码、34 个编译类文件为主辅以 jar 依赖、yml 与 properties 配置、SQL 脚本及数据库建模文件覆盖从数据建模到后端逻辑再到前端交互的完整链路。系统围绕用户管理、角色管理、角色资源与权限分配、日志记录展开并设有回收站机制可恢复误删数据体现了权限控制与数据操作的严谨性。已有 1463 人学习下载读者可从中获取可运行的项目骨架、清晰的模块划分与权限设计思路便于对照理解 SpringBoot 快速开发、MyBatis 数据映射及 Vue 响应式界面的协同方式。1. 权限管理系统一套 SpringBootMyBatisVue 的 RBAC 落地骨架后台系统做到第三个月最先崩的往往不是业务逻辑而是权限。菜单越加越多、角色越分越细某天运营提了个需求“让财务能看订单但不能改让客服只能看自己片区的工单”你打开数据库一看用户表里挂着一个role字段值还是1、2、3这种魔法数字改一次权限就得改代码发版。这套基于 SpringBoot MyBatis Vue.js 的权限管理系统解决的正是这个从“能登录”到“能精细控权”的过渡问题。它把用户、角色、菜单/权限点拆成标准 RBAC 模型后端用 SpringBoot 提供接口、MyBatis 做数据访问前端 Vue 负责动态路由和按钮级显隐。适合正在搭后台脚手架的后端、需要一套能直接改的全栈练习项目的开发者也适合想搞清楚“动态菜单到底怎么从数据库流到浏览器”的人。下面按我拆这套东西的顺序讲模型怎么设计、接口怎么串、前端怎么接、哪里最容易翻车。2. RBAC 数据模型与 MyBatis 映射五张表撑起整套鉴权权限系统的地基不在代码在表结构。表设计歪了后面接口写得再漂亮也是补丁摞补丁。这套系统采用的是经典 RBAC基于角色的访问控制变体核心是“用户不直接挂权限用户挂角色角色挂权限点”中间用关联表解耦。先把这个模型立住再谈 MyBatis 怎么映射。2.1 五张核心表与字段取舍常见做法是五张表用户表、角色表、权限菜单表外加两张关联表。权限表这里有个关键取舍——菜单和按钮权限到底放一张表还是两张表。这套系统走的是单表方案用type字段区分目录、菜单、按钮好处是树形结构统一、前端一次拉全量再过滤坏处是查询时要靠type和parent_id双重条件筛。字段大致如下表名关键字段说明sys_userid, username, password, status密码存 BCrypt 哈希不存明文sys_roleid, role_name, role_key, statusrole_key 是英文标识如admin、financesys_menuid, parent_id, menu_name, path, perms, menu_typeperms 存权限标识如order:editmenu_type 区分 M/C/Fsys_user_roleuser_id, role_id联合主键防重复授权sys_role_menurole_id, menu_id角色与权限点的多对多perms这个字段是整套系统的“暗号”后端接口上的权限注解、前端按钮的v-hasPermi指令认的都是它。设计时建议统一命名规范模块:操作比如user:add、user:remove别用中文也别用纯数字否则后期排查权限不生效时你会怀疑人生。2.2 MyBatis 多表关联查询与结果映射权限系统里最高频的查询是“根据用户 ID 查出他所有权限标识”这条 SQL 要跨四张表。用 MyBatis 的resultMap做嵌套映射比在 Java 里循环查库干净得多。核心 mapper 写法!-- SysMenuMapper.xml按用户ID查权限标识集合 -- select idselectPermsByUserId resultTypejava.lang.String SELECT DISTINCT m.perms FROM sys_menu m LEFT JOIN sys_role_menu rm ON m.id rm.menu_id LEFT JOIN sys_user_role ur ON rm.role_id ur.role_id LEFT JOIN sys_role r ON ur.role_id r.id WHERE ur.user_id #{userId} AND m.status 0 AND r.status 0 AND m.perms IS NOT NULL AND m.perms ! /select这段 SQL 的逻辑是从用户出发经sys_user_role找到角色再经sys_role_menu找到菜单最后筛出非空的perms。三个status 0条件是血泪经验——用户被禁用、角色被停用、菜单被隐藏时权限必须同步失效否则会出现“账号已停用但接口还能调”的诡异现象。DISTINCT不能省一个用户挂多个角色时权限会重复虽然Set能去重但在 SQL 层去掉能减少传输量。对应的 Java 接口和 Service 层调用// SysMenuMapper.java public interface SysMenuMapper { // 查询用户权限标识集合 SetString selectPermsByUserId(Long userId); // 查询用户可见菜单树用于动态路由 ListSysMenu selectMenuTreeByUserId(Long userId); }参数说明userId来自登录后解析的 Token不要从请求参数里取否则等于把越权入口直接开给前端。selectMenuTreeByUserId返回的是扁平列表树形组装放在 Service 层做因为 MyBatis 递归查询在数据量大时性能不可控常见做法是一次查全量再在内存里按parent_id拼树。2.3 权限校验的两种落点注解拦截与手动判断权限查出来了得有用武之地。这套系统在后端用 Spring Security 的PreAuthorize注解做接口级拦截配合自定义的PermissionService// 自定义权限判断供 PreAuthorize 调用 Service(ss) public class PermissionService { // 判断当前登录用户是否拥有某权限标识 public boolean hasPermi(String permission) { if (StringUtils.isEmpty(permission)) { return false; } LoginUser loginUser SecurityUtils.getLoginUser(); if (loginUser null || CollectionUtils.isEmpty(loginUser.getPermissions())) { return false; } // 超级管理员放行避免配置遗漏锁死自己 return loginUser.getPermissions().contains(permission) || loginUser.getPermissions().contains(*:*:*); } }控制器上这样用PreAuthorize(ss.hasPermi(user:add)) PostMapping(/add) public AjaxResult add(RequestBody SysUser user) { return toAjax(userService.insertUser(user)); }逻辑说明ss是 Bean 名hasPermi是方法名Spring Security 会在方法执行前调用它返回false直接抛 403。参数user:add必须和数据库perms字段一字不差大小写敏感。这里有个容易忽略的点——超级管理员用*:*:*通配符放行但别在数据库里给普通角色配这个值否则等于开了后门。手动判断的场景比如列表接口要根据权限过滤数据范围则直接注入PermissionService调hasPermi别在 Controller 里写if (role.equals(admin))这种硬编码角色名一改就全崩。3. 登录鉴权链路从 Token 签发到动态路由下发后端模型通了接下来是“人怎么进来、进来后看到什么”。这条链路是权限系统最容易出玄学问题的地方Token 明明有效却 401、菜单明明配了却不显示、刷新页面路由丢失。把链路拆成三段看就清楚了——登录签发、请求校验、菜单下发。3.1 登录接口与 JWT 载荷设计登录接口做三件事校验账号密码、查权限集合、签发 Token。密码用 BCrypt 比对别用 MD5盐值内置在哈希里省去自己管理盐的麻烦。Token 用 JWT载荷里只放必要信息// 登录成功后组装 LoginUser 并生成 Token public String createToken(LoginUser loginUser) { // 生成唯一标识用于服务端缓存踢人 String uuid UUID.randomUUID().toString(); loginUser.setToken(uuid); // 权限集合放入服务端缓存不塞进 JWT避免 Token 过大 redisCache.setCacheObject(login_tokens: uuid, loginUser, 30, TimeUnit.MINUTES); MapString, Object claims new HashMap(); claims.put(login_user_key, uuid); return JwtUtils.createToken(claims); }参数说明uuid是服务端会话的钥匙JWT 里只放这个 key真正的用户信息和权限集合放 Redis。这样做的好处是权限变更后能立刻生效——改完角色权限删掉对应缓存用户下次请求重新加载即可如果把权限直接写进 JWT就得等 Token 过期才生效运营改完权限发现没反应第一个找的就是你。过期时间设 30 分钟配合前端静默刷新别设太长也别设太短导致频繁掉线。3.2 拦截器校验与 Token 续期每个请求进来过滤器从 Header 取Authorization解析出login_user_key再去 Redis 捞LoginUser。捞不到就 401捞到了就刷新过期时间// JwtAuthenticationTokenFilter 核心逻辑 String token getToken(request); if (StringUtils.isNotEmpty(token)) { Claims claims JwtUtils.parseToken(token); String uuid (String) claims.get(login_user_key); LoginUser loginUser redisCache.getCacheObject(login_tokens: uuid); if (loginUser ! null) { // 剩余有效期不足 20 分钟则自动续期 verifyToken(loginUser); // 将用户信息放入 SecurityContext供后续注解校验使用 UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } }逻辑说明verifyToken里判断剩余时间不足阈值就重设 30 分钟这就是“无感续期”。注意SecurityContextHolder是线程绑定的过滤器里设置完要放行chain.doFilter别在过滤器里直接return否则请求链断了。还有个坑——异步任务或子线程里拿不到SecurityContext需要手动传递权限系统里导出报表这类异步操作最容易踩。3.3 前端动态路由后端菜单树怎么变成 Vue 路由这是整套系统里最值得细看的一段。前端登录后调/getRouters拿到菜单树然后动态addRoute。核心转换逻辑// permission.js把后端菜单树转成 Vue Router 路由 function filterAsyncRouter(asyncRouterMap) { return asyncRouterMap.filter(route { if (route.component) { // Layout 特殊处理其余按路径懒加载 if (route.component Layout) { route.component Layout } else { route.component loadView(route.component) } } if (route.children route.children.length) { route.children filterAsyncRouter(route.children) } return true }) } // loadView 用动态 import 加载视图组件 const loadView (view) { return () import(/views/${view}) }参数说明后端返回的component字段存的是相对路径字符串比如system/user/index前端拼成/views/system/user/index再动态导入。这里有个硬约束——数据库里配的路径必须和views目录下的文件结构严格对应少一层目录就白屏控制台报Cannot find module。Layout是父级容器组件目录级菜单都指向它叶子菜单才指向真实页面。路由守卫里还要处理刷新丢失问题addRoute加的路由在刷新后消失常见做法是在router.beforeEach里判断路由表是否已加载没加载就重新拉菜单再next({ ...to, replace: true })别直接next()否则会白屏一次。4. 按钮级权限与数据权限把控制粒度压到操作层菜单级权限只解决“能不能进这个页面”真正让业务方满意的是“进了页面能点哪些按钮、能看到哪些数据”。这两层是权限系统从“能用”到“好用”的分水岭也是新手项目最容易糊弄过去的地方。4.1 前端 v-hasPermi 指令实现按钮显隐按钮级控制靠自定义指令从登录时缓存的权限集合里判断// hasPermi.js按钮权限指令 export default { mounted(el, binding) { const { value } binding const all_permission *:*:* // 从 store 取当前用户权限集合 const permissions store.getters store.getters.permissions if (value value instanceof Array value.length 0) { const permissionFlag value const hasPermissions permissions.some(permission { return all_permission permission || permissionFlag.includes(permission) }) // 无权限则移除 DOM而非隐藏 if (!hasPermissions) { el.parentNode el.parentNode.removeChild(el) } } else { throw new Error(请设置操作权限标签值) } } }逻辑说明指令在mounted时执行无权限直接removeChild而不是display: none。为什么因为隐藏的按钮还在 DOM 里稍微懂点前端的人打开控制台改个样式就能点虽然后端还有一层拦截但没必要留这个口子。用法是el-button v-hasPermi[user:add]新增/el-button数组形式支持“或”关系任一权限命中即显示。注意指令只控制显隐真正的安全边界永远在后端PreAuthorize前端这层是体验优化不是安全措施这个认知不能颠倒。4.2 数据权限部门维度的 SQL 拼接数据权限比按钮权限复杂因为它要改 SQL。常见做法是在 MyBatis 拦截器里根据当前用户的角色数据范围动态拼接AND条件。这套系统用DataScope注解标记需要过滤的查询// DataScopeAspect切面处理数据权限 Before(annotation(controllerDataScope)) public void doBefore(JoinPoint point, DataScope controllerDataScope) { LoginUser loginUser SecurityUtils.getLoginUser(); SysUser user loginUser.getUser(); // 超级管理员不过滤 if (user.isAdmin()) { return; } // 根据角色数据范围拼接 SQL 片段 ListSysRole roles user.getRoles(); StringBuilder sqlString new StringBuilder(); for (SysRole role : roles) { String dataScope role.getDataScope(); if (1.equals(dataScope)) { // 全部数据不拼条件 sqlString.setLength(0); break; } else if (2.equals(dataScope)) { // 自定义部门 sqlString.append(StringUtils.format( OR {}.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id {}) , controllerDataScope.deptAlias(), role.getRoleId())); } else if (3.equals(dataScope)) { // 本部门 sqlString.append(StringUtils.format( OR {}.dept_id {} , controllerDataScope.deptAlias(), user.getDeptId())); } } // 把拼好的条件塞进查询参数 if (StringUtils.isNotBlank(sqlString.toString())) { BaseEntity baseEntity (BaseEntity) point.getArgs()[0]; baseEntity.getParams().put(dataScope, AND ( sqlString.substring(4) )); } }参数说明deptAlias是表别名比如u拼出来的条件形如AND (u.dept_id 100)。substring(4)是去掉开头的OR。这段逻辑的坑在于——拼出来的 SQL 片段最终要塞进 mapper 的${params.dataScope}必须用${}而非#{}因为是 SQL 片段不是参数值。但${}有注入风险所以拼接内容必须全部来自数据库里的角色配置绝不能掺入任何前端传参。另外多角色取并集还是交集要想清楚这套实现是并集OR意味着用户挂两个角色时数据范围会放大如果业务要求“取最严”得改成取最小范围这是选型时要和业务确认的点。5. 避坑与排查权限系统上线前必须过的五道坎这套东西跑通 demo 不难难的是上线后不出幺蛾子。下面五条是我拆这类项目时反复遇到的每条都按“现象 → 原因 → 解决”记照着排能省不少时间。5.1 菜单配了但不显示现象数据库sys_menu里明明加了记录角色也关联了前端登录后菜单树里就是没有。原因通常有三个status字段不是0、visible字段设成了隐藏、或者parent_id指向了一个不存在的父节点导致整棵子树被丢弃。解决先查SELECT * FROM sys_menu WHERE menu_name xxx看三个状态字段再顺着parent_id往上追确认父链完整。树形组装时如果父节点缺失子节点会被静默丢弃这个“黑匣子”行为最坑。5.2 接口 403 但权限明明配了现象按钮能显示点下去接口返回 403。原因perms字段值和PreAuthorize里的字符串不一致常见于大小写、前后空格、或者用了中文冒号。解决在PermissionService.hasPermi里打日志把当前用户权限集合和请求的权限标识都打出来对比。另外检查角色status角色停用后权限集合为空也会 403 但报错信息一样容易误判。5.3 刷新页面后动态路由丢失现象登录后菜单正常F5 刷新一下页面空白或跳 404。原因addRoute加的路由存在内存里刷新即丢而路由守卫没做重新加载。解决在router.beforeEach里判断store.getters.routes是否为空为空则重新调/getRouters并next({ ...to, replace: true })。注意replace: true不能省否则历史记录里会多一条用户点后退会来回跳。5.4 修改权限后不生效现象运营在后台改了角色权限用户那边毫无变化。原因权限集合缓存在 Redis 的LoginUser里改数据库不会自动刷新缓存。解决角色/菜单变更的 Service 方法里主动清理关联用户的缓存 key。简单做法是遍历该角色下的用户删掉login_tokens:*粗暴做法是清空整个前缀但会影响所有在线用户。建议前者虽然多几行代码但用户体验差别很大。5.5 数据权限 SQL 拼接报语法错现象带DataScope的查询报SQL syntax error。原因${params.dataScope}拼进去后如果sqlString为空或格式不对会拼出AND ()这种非法片段。解决切面里判断sqlString非空才塞参数mapper 里用if testparams.dataScope ! null and params.dataScope ! 包住。另外多角色拼接时注意OR和AND的优先级最外层必须用括号包住整个条件组否则会和其它WHERE条件串味。6. 二次开发与验证用一条权限链路自测整套系统拿到这套源码别急着改业务先跑通一条完整的权限链路做自测这是判断“这套东西到底能不能用”最快的方式。我的习惯是新建一个测试角色只给它一个按钮权限然后从登录到点击走一遍看每一层是否都拦得住。具体做法在sys_role建一个test_rolesys_menu里挑一个按钮权限比如user:export关联给它再建个测试用户挂这个角色。登录后预期结果是——菜单里能看到用户管理页但“导出”按钮不显示因为只给了user:export没给菜单权限的话页面都进不去所以实际要同时给菜单权限和这一个按钮权限。然后手动在控制台把按钮 DOM 改出来点一下后端应该返回 403。这一套走完说明前端指令、后端注解、缓存刷新三个环节都是通的。验证 Token 续期是否生效可以把 Redis 里login_tokens:*的 TTL 改成 60 秒然后连续操作观察 key 的过期时间是否被重置。验证数据权限用两个不同部门的账号查同一个列表接口对比返回条数。这些验证不需要写测试用例手工走一遍比什么都直观。进阶一点的做法是把权限标识做成常量类前后端共用一份枚举避免字符串手写不一致// 权限常量与前端约定同一套标识 public class PermsConstants { public static final String USER_ADD user:add; public static final String USER_EDIT user:edit; public static final String USER_EXPORT user:export; // 新增权限点时在此登记前端同步维护 }这样改权限标识时全局搜索一处即可比在几十个PreAuthorize里逐个改靠谱。从那以后我每次接这类权限系统都强制先跑一遍“单按钮权限”的端到端验证再动业务代码——因为权限这东西配错了不会报错只会静默放行或静默拦截等上线后才发现就晚了。希望这套拆解能帮到你少走几段我踩过的弯路。本文还有配套的精品资源点击获取