尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RBAC权限管理实战从数据库表设计到前后端落地

RBAC权限管理实战从数据库表设计到前后端落地 做权限系统之前我一直觉得这就是“用户表、角色表、权限表”三张表的事直到接过一个被权限代码写成一锅粥的存量项目——业务代码里到处散落着if(user.isAdmin())、每个Controller都要手写一遍角色判断、菜单写死在前端路由里新来一个需求要改五六个地方。那一刻我意识到权限这件事看起来简单实际上从模型设计到前后端落地每一个环节都有坑。这篇文章就围绕RBAC权限管理模型展开从模型原理、数据库表设计到Java后端的Spring Security JWT实现再到Vue前端的动态菜单和按钮权限控制完整走一遍我在实际项目里的落地过程。适合正在做管理系统、需要从头搭建权限体系的同学也适合面试前想系统梳理RBAC知识点的朋友文章里的SQL和代码都是能直接拿过去改的。1. 为什么选RBAC模型权限设计的核心思路1.1 ACL与RBAC权限模型演进到底解决了什么问题在开始写代码之前先搞清楚一个根本问题为什么大家都在用RBAC而不是把权限直接挂在用户身上最早的权限模型叫ACLAccess Control List思路非常直白给每个用户直接配置他能访问的资源。用户少的时候没问题比如一套内部系统只有20个人挨个配一遍也就一下午。但用户一旦多起来比如1000个用户、100个功能点直接在用户身上挂权限就要维护1000条甚至更多的授权记录新增一个功能就要给所有人重新赋权离职一个人要清理他名下所有细碎权限这种模式根本没法维护。RBACRole-Based Access Control基于角色的访问控制的核心转变是在用户和权限之间加了一层“角色”。用户不再直接和权限绑定而是先给角色分配权限再把角色授予用户。这样做的好处是权限管理的粒度从“用户”变成了“角色”。1000个用户最多也就几十种角色新增功能只需要给对应角色加权限入职新人只需要分配角色离职人员只需要移除角色管理成本降了一个数量级。用一个生活化的类比来理解ACL就像每进一个小区都要单独登记你的车牌号RBAC就是给你发一张门禁卡物业只需要决定这张卡能刷开哪些门而不是记录每个业主的车牌。这个“卡”的角色就是RBAC里的角色。1.2 RBAC0到RBAC3实际项目该用哪一个RBAC在学术上有完整的分级模型很多人在看资料的时候被RBAC0、RBAC1、RBAC2这些编号绕晕了这里用大白话梳理一下。RBAC0基础模型只包含用户、角色、权限三要素以及用户-角色、角色-权限两组关系。这是最常用的模型大多数中小型系统用这个就够了。RBAC1在RBAC0的基础上增加了角色继承。比如“部门经理”这个角色天然拥有“普通员工”的全部权限就可以让部门经理继承普通员工避免重复授权。RBAC2在RBAC0的基础上增加了约束比如角色互斥一个人不能同时是采购员和验收员、角色基数限制一个角色最多只能有10个人。RBAC3RBAC1 RBAC2最完整的模型。这里要说明一下RBAC1和RBAC2看起来更高大上实际落地时却会带来不少复杂度。角色继承会让权限查询变成递归角色互斥又需要在授权接口里写一堆校验逻辑。从工程角度讲绝大多数管理系统的权限需求RBAC0就足够了。我在实际项目里也基本只用RBAC0配合一个“超级管理员”的特殊标识解决最高权限的问题简单可靠出了问题也好排查。1.3 这套模型最终解决了几件事结合我做的这个项目RBAC模型最终要解决的核心问题其实是三个第一访问认证。用户登录后系统要确认“你是谁”。这里用JWT令牌解决令牌里携带用户唯一标识。第二接口授权。请求访问某个接口时系统要判断“你能不能做这件事”。比如只有拥有user:add权限的用户才能调用新增用户的接口。第三前端菜单和按钮控制。用户登录后前端菜单要按角色展示按钮也要根据权限标识显示或隐藏。比如普通用户看不到用户管理菜单运营人员能看到但看不到删除按钮。这三件事分别对应了后端的认证过滤器、方法级别权限校验、前端动态路由在后续的章节里会逐一展开。2. 数据库表设计5张核心表搞定权限存储2.1 逐表拆解字段、类型、约束设计说明RBAC0标准的数据库设计是5张表用户表、角色表、权限表加上用户-角色关联表、角色-权限关联表。如果菜单需要树形结构权限表会额外加一个父ID字段自己关联自己这在我这个项目里是标配。先看用户表这是最基础的一张表。我重点说一下字段设计的思路而不是简单贴一堆字段。CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 用户ID, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密, nickname varchar(50) DEFAULT NULL COMMENT 昵称, email varchar(100) DEFAULT NULL COMMENT 邮箱, status tinyint(1) DEFAULT 1 COMMENT 状态1启用 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, deleted tinyint(1) DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT用户表;字段设计上有几个细节值得注意。password用的是BCrypt加密长度设定为100这是因为BCrypt生成的哈希字符串是60位留足余量。用户名加唯一索引避免系统里出现重名账号。deleted字段做逻辑删除而不是物理删除这是后台系统的通用做法权限一旦删错逻辑删除还能找回数据。角色表相对简单但“角色编码”这个字段是精髓。CREATE TABLE sys_role ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 角色ID, role_name varchar(50) NOT NULL COMMENT 角色名称, role_code varchar(50) NOT NULL COMMENT 角色编码, description varchar(200) DEFAULT NULL COMMENT 角色描述, status tinyint(1) DEFAULT 1 COMMENT 状态, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint(1) DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_role_code (role_code) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT角色表;为什么一定要有role_code因为角色名称可能随时改比如把“超级管理员”改成“系统管理员”如果代码里写死判断roleName 超级管理员改名就崩了。用稳定不变的role_code作为代码里的判断依据比如admin、manager、normal名称怎么改都不影响逻辑。我之前接过一个项目就是代码里到处判断角色名称被坑得不轻。权限表是这张设计里最灵活也最需要想清楚的一张表它同时管理菜单和按钮权限。CREATE TABLE sys_permission ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 权限ID, parent_id bigint(20) DEFAULT 0 COMMENT 父权限ID目录/菜单为0按钮指向所属菜单, perm_name varchar(50) NOT NULL COMMENT 权限名称, perm_code varchar(100) DEFAULT NULL COMMENT 权限标识如user:add、user:delete, menu_type tinyint(1) NOT NULL COMMENT 类型1目录 2菜单 3按钮, path varchar(200) DEFAULT NULL COMMENT 路由路径菜单类使用, component varchar(200) DEFAULT NULL COMMENT 前端组件路径, icon varchar(50) DEFAULT NULL COMMENT 图标, sort int(11) DEFAULT 0 COMMENT 排序, status tinyint(1) DEFAULT 1 COMMENT 状态, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT权限表;这个表的设计思路是“菜单和权限一体”目录和菜单记录前端路由信息path、component、icon按钮记录权限标识perm_code用menu_type字段区分。后端在做接口授权时使用的是perm_code前端在生成动态菜单时使用的是path和component数据同源维护起来很方便。关联表的设计比较直白就是两张纯关系表。CREATE TABLE sys_user_role ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, role_id bigint(20) NOT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_role_id (role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户角色关联表; CREATE TABLE sys_role_permission ( id bigint(20) NOT NULL AUTO_INCREMENT, role_id bigint(20) NOT NULL, permission_id bigint(20) NOT NULL, PRIMARY KEY (id), KEY idx_role_id (role_id), KEY idx_permission_id (permission_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色权限关联表;两张关联表都只建立普通索引不建联合唯一索引。原因在于后续做权限分配时前端交互往往是一口气勾选多个权限服务端处理时先删除后批量插入如果建了联合唯一索引反而增加麻烦。两张表的字段都很少没必要为了“省一次查询”去加冗余设计。2.2 初始化SQL脚本与测试数据把上面的表和测试数据合成一份可以直接执行的SQL文件这也是网上流传最广的RBAC标准五表结构。测试数据我造了一套典型的后台管理系统权限一个管理员角色拥有全部权限一个普通用户角色只拥有部分菜单和按钮权限。-- 用户表 INSERT INTO sys_user (id, username, password, nickname, status) VALUES (1, admin, $2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2, 管理员, 1), (2, zhangsan, $2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2, 张三, 1); -- 角色表 INSERT INTO sys_role (id, role_name, role_code, description) VALUES (1, 超级管理员, admin, 拥有全部权限), (2, 普通用户, normal, 拥有部分权限); -- 权限表目录-菜单-按钮三级结构 INSERT INTO sys_permission (id, parent_id, perm_name, perm_code, menu_type, path, component, icon, sort) VALUES (1, 0, 系统管理, NULL, 1, /system, NULL, Setting, 1), (2, 1, 用户管理, system:user:list, 2, /system/user, system/user/index, User, 1), (3, 2, 新增用户, system:user:add, 3, NULL, NULL, NULL, 1), (4, 2, 删除用户, system:user:delete, 3, NULL, NULL, NULL, 2), (5, 2, 重置密码, system:user:resetPwd, 3, NULL, NULL, NULL, 3), (6, 1, 角色管理, system:role:list, 2, /system/role, system/role/index, UserFilled, 2), (7, 6, 新增角色, system:role:add, 3, NULL, NULL, NULL, 1), (8, 6, 删除角色, system:role:delete, 3, NULL, NULL, NULL, 2); -- 用户-角色关联1号用户是管理员2号用户是普通用户 INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1), (2, 2); -- 角色-权限关联超级管理员拥有权限ID 1~8普通用户只有菜单查看和新增加载权限 INSERT INTO sys_role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (2, 1), (2, 2), (2, 3), (2, 6), (2, 7);注意上面密码字段展示的是同一个BCrypt加密串这是我预先用固定值生成的方便测试。实际项目里你的密码加密结果会是完全不同的随机串BCrypt每次生成的哈希都不一样后面我会解释为什么。这份SQL跑完之后系统里就有了一个能跑通完整权限链路的初始数据管理员能看全部菜单并能删除用户普通用户只能看用户列表和角色列表且只有“新增用户”和“新增角色”的按钮权限看不到删除按钮。2.3 权限表设计避坑指南表结构看起来简单实际落地时有几个坑值得拿出来单独说。第一个坑是“要不要用数据库外键”。我的建议是不要。用外键确实能保证数据一致性但RBAC的表是典型的弱关联场景删除一个角色时系统要先处理角色和权限的关系删除一个用户时要先解除用户和角色的绑定。如果数据库层面有外键约束删除顺序错一步就报错给后续代码和运维都添麻烦。我平时只建索引不用外键通过业务代码保证关联数据的一致性这符合主流项目的实践方式。第二个坑是“逻辑删除和唯一索引冲突”。sys_user表如果加了逻辑删除字段deleted又给username建唯一索引删除一个用户后再创建一个同名的用户就会报唯一索引冲突。常见做法是用户名带上删除时间后缀或者干脆物理删掉账号数据保留关联表的操作日志。具体取舍看业务但提前想清楚能省很多事。第三个坑是“权限表要不要冗余删除标识”。我见过不少项目在权限表上也加deleted字段实际却不怎么用。权限表本身数据量小且稳定权限出了问题影响面大一般用状态字段status来启停而不是逻辑删除这样数据更干净关联表也不会积累垃圾数据。3. Java后端Spring Security JWT 认证与动态授权3.1 技术选型与项目目录结构实际项目里我用的技术栈是Spring Boot 2.7 Spring Security JWT MyBatis Plus Redis这套组合在Java后台管理系统里非常主流。Spring Security负责认证和授权框架JWT负责无状态令牌MyBatis Plus负责数据库操作Redis用来缓存登录用户和权限信息。先看项目目录结构按功能分包权限相关的代码集中在security、system两个包下com.example.rbac ├── common │ ├── Result.java // 统一返回结果 │ └── exception │ ├── BusinessException.java │ └── GlobalExceptionHandler.java ├── config │ ├── SecurityConfig.java // Spring Security核心配置 │ └── MybatisPlusConfig.java // 分页插件等 ├── security │ ├── JwtUtil.java // JWT生成与解析 │ ├── LoginUser.java // 实现UserDetails的登录用户对象 │ ├── JwtAuthenticationTokenFilter.java // Token认证过滤器 │ └── AccessDeniedHandlerImpl.java // 403处理 ├── controller │ ├── AuthController.java // 登录、登出、获取用户信息 │ └── UserController.java // 用户CRUD带权限注解 ├── service │ └── impl ├── mapper ├── entity ├── dto └── vo关于效率实体类和Mapper用MyBatis Plus的代码生成器生成就行这几张表结构固定生成器一次能产出全部基础代码省下的时间用来调权限逻辑。3.2 登录认证与Token签发流程登录流程是权限体系的入口。用户提交用户名和密码后端校验通过后签发JWT前端保存JWT并在后续请求的请求头中携带。代码如下。RestController RequestMapping(/auth) public class AuthController { Autowired private AuthService authService; PostMapping(/login) public ResultString login(RequestBody LoginDTO loginDTO) { return Result.ok(authService.login(loginDTO)); } GetMapping(/info) public ResultUserInfoVO getInfo() { LoginUser loginUser SecurityUtils.getLoginUser(); return Result.ok(authService.getUserInfo(loginUser.getUserId())); } }Service public class AuthServiceImpl implements AuthService { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; Autowired private RedisService redisService; Override public String login(LoginDTO loginDTO) { // 1. 使用Spring Security的AuthenticationManager进行认证 UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword()); Authentication authenticate authenticationManager.authenticate(authenticationToken); // 2. 认证成功拿到登录用户 LoginUser loginUser (LoginUser) authenticate.getPrincipal(); // 3. 从登录用户对象中提取权限标识列表 ListString perms loginUser.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); // 4. 签发JWT把用户ID放进Token String token jwtUtil.createToken(loginUser.getUserId()); // 5. 用Redis缓存登录用户信息和权限设置过期时间与Token一致 redisService.set(login: loginUser.getUserId(), loginUser, jwtUtil.getExpire()); return token; } }这段登录逻辑有四个关键点。第一AuthenticationManager是Spring Security的认证入口。实际上它内部会调用我们实现的UserDetailsService.loadUserByUsername()方法去数据库查用户然后比对密码。所以我们需要自己写一个UserDetailsServiceImpl职责就是从sys_user表查出用户再从sys_user_role、sys_role、sys_role_permission、sys_permission四张关联表查出这个用户的所有权限标识组装成一个LoginUser对象返回。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private SysUserMapper userMapper; Autowired private SysPermissionMapper permissionMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户名或密码错误); } if (user.getStatus() 0) { throw new BusinessException(账号已被禁用); } // 查询该用户拥有的权限标识列表 ListString perms permissionMapper.selectPermsByUserId(user.getId()); return new LoginUser(user, perms); } }第二LoginUser这个类必须实现Spring Security的UserDetails接口否则AuthenticationManager返回的Principal无法向下转型。这个类就是把用户实体和权限列表包装一下。public class LoginUser implements UserDetails { private SysUser user; private ListString permissions; public LoginUser(SysUser user, ListString permissions) { this.user user; this.permissions permissions; } public Long getUserId() { return user.getId(); } Override public Collection? extends GrantedAuthority getAuthorities() { return permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); } Override public String getPassword() { return user.getPassword(); } Override public String getUsername() { return user.getUsername(); } Override public boolean isAccountNonExpired() { return true; } Override public boolean isAccountNonLocked() { return true; } Override public boolean isCredentialsNonExpired() { return true; } Override public boolean isEnabled() { return user.getStatus() 1; } }第三查询权限的SQL是这套系统的核心查询之一这里会联查出用户的所有权限标识。SQL语句如下select idselectPermsByUserId resultTypejava.lang.String SELECT DISTINCT p.perm_code FROM sys_user u INNER JOIN sys_user_role ur ON u.id ur.user_id INNER JOIN sys_role r ON ur.role_id r.id AND r.status 1 AND r.deleted 0 INNER JOIN sys_role_permission rp ON r.id rp.role_id INNER JOIN sys_permission p ON rp.permission_id p.id WHERE u.id #{userId} AND p.perm_code IS NOT NULL AND p.perm_code ! /select这里有两点要注意。一是加了DISTINCT去重因为一个用户可能有多个角色多个角色可能拥有同一个权限标识。二是perm_code IS NOT NULL过滤只保留按钮级别的权限标识目录和菜单的perm_code通常是空值后端接口授权只看按钮权限。3.3 授权链路的完整实现登录之后的每一次请求都要经过一次“Token解析 - 查询用户 - 塞入上下文 - 方法级别校验”的完整链路。这个链路的核心是两个类过滤器JwtAuthenticationTokenFilter和配置类SecurityConfig。过滤器的作用是拦截每次请求、解析JWT、还原登录状态并放入Spring Security上下文。Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private RedisService redisService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); try { Long userId jwtUtil.getIdFromToken(token); if (userId ! null) { LoginUser loginUser redisService.get(login: userId, LoginUser.class); if (loginUser ! null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // Token无效或过期不设置上下文后续会被认证入口拦截 } } filterChain.doFilter(request, response); } }这里我把登录用户信息放在Redis里而不是塞进JWT里。原因是权限可能随时调整如果权限列表存在JWT里修改了角色授权要等Token过期才生效体验很差存在Redis里则可以在授权变更时同步刷新还要等Token过期才生效。SecurityConfig是整个安全配置的核心需要明确哪些接口放行、哪些接口必须认证、如何接入JWT过滤器。Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; Autowired private AccessDeniedHandlerImpl accessDeniedHandler; Autowired private AuthenticationEntryPointImpl authenticationEntryPoint; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder()); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/auth/login).permitAll() .antMatchers(/doc.html, /webjars/**, /v3/api-docs/**).permitAll() .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) .and() .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } }配置里有几个容易踩坑的点。EnableGlobalMethodSecurity(prePostEnabled true)必须加否则PreAuthorize注解不生效sessionCreationPolicy(SessionCreationPolicy.STATELESS)设置无状态会话因为JWT本身就是无状态的写接口授权时用于登录认证后续接口从Redis里读。4.2 动态菜单与动态路由实现登录成功后前端拿着用户信息去请求/auth/info后端返回菜单树和权限标识列表前端根据这些数据动态生成路由和菜单。菜单树查询逻辑在上一章的AuthServiceImpl.getUserInfo()里查询SQL类似select idselectMenusByUserId resultTypecom.example.rbac.vo.MenuVO SELECT DISTINCT p.id, p.parent_id, p.perm_name, p.path, p.component, p.icon, p.sort, p.menu_type FROM sys_user u INNER JOIN sys_user_role ur ON u.id ur.user_id INNER JOIN sys_role r ON ur.role_id r.id AND r.status 1 INNER JOIN sys_role_permission rp ON r.id rp.role_id INNER JOIN sys_permission p ON rp.permission_id p.id WHERE u.id #{userId} AND p.status 1 AND p.menu_type IN (1, 2) ORDER BY p.sort /select拿到的是一个扁平列表需要在后端或前端组装成树结构。后端组装的好处是前端更轻量我一般用MyBatis Plus的listWithTree()思路配合递归实现也可以直接用Java 8的Stream流进行两遍遍历组装这种写法也更简洁。前端拿到菜单树之后要做两件事生成侧边栏菜单和动态注册路由。动态路由是Vue框架里的一个重要操作要在路由守卫里完成。// router/index.js // 静态路由登录页、404等 export const constantRoutes [ { path: /login, component: () import(/views/login/index.vue) } ]; // 格式化后端菜单为路由对象 function formatRoutes(menus) { return menus.map(menu { const route { path: menu.path, name: menu.permName, component: loadView(menu.component), meta: { title: menu.permName, icon: menu.icon }, }; if (menu.children menu.children.length 0) { route.children formatRoutes(menu.children); } return route; }); } function loadView(component) { return () import(/views/${component}.vue); }// permission.js 路由守卫 import router from /router; import { getInfo } from /api/auth; router.beforeEach(async (to, from, next) { const token localStorage.getItem(token); if (!token) { if (to.path /login) return next(); return next({ path: /login }); } if (to.path /login) return next({ path: / }); const userStore useUserStore(); if (!userStore.perms.length) { try { const { menus, perms } await getInfo(); userStore.setPerms(perms); // 动态注册路由 const dynamicRoutes formatRoutes(menus); dynamicRoutes.forEach(route { router.addRoute(Layout, route); }); // 添加404兜底 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }); // 关键动态路由可能还没加载完重新进入一次next return next({ ...to, replace: true }); } catch (e) { localStorage.removeItem(token); return next({ path: /login }); } } next(); });路由守卫里那个return next({ ...to, replace: true })是个很关键的细节。动态路由在addRoute之后还没完全生效直接next()会导致首次进入目标页面时匹配不到路由跳404。重新进入一次next让路由表完成刷新这个坑我在初学阶段反复踩过。4.3 按钮级权限指令 v-permission菜单动态化解决的是“能看到什么页面”的问题但页面内部的按钮权限怎么控制比如用户管理页面普通用户能看到页面但“删除用户”按钮要隐藏。最直接的做法是v-if结合权限数组判断但每个按钮都写一遍冗长的判断代码会很啰嗦。Vue自定义指令是最优雅的方案。// 自定义指令 v-permission import { useUserStore } from /store/modules/user; export const permission { mounted(el, binding) { const userStore useUserStore(); // binding.value 是需要的权限标识可以传字符串或数组 const required binding.value; const perms userStore.perms; let hasPermission false; if (typeof required string) { hasPermission perms.includes(required); } else if (Array.isArray(required)) { // 传数组时默认要求满足全部权限 hasPermission required.every(perm perms.includes(perm)); } if (!hasPermission) { // 移出DOM节点 el.parentNode el.parentNode.removeChild(el); } } };在入口文件里注册// main.js import { permission } from /directive/permission; app.directive(permission, permission);在页面里使用el-button v-permissionsystem:user:add typeprimary新增用户/el-button el-button v-permissionsystem:user:delete typedanger删除用户/el-button这个指令的逻辑很简单元素挂载时检查用户权限列表没有对应权限就移除DOM。但使用中有个特性要注意——如果权限信息是在路由守卫里异步加载的而页面组件在权限加载完成前已经渲染指令的mounted钩子可能拿不到perms。所以务必保证在渲染页面组件之前用户的角色、权限信息已经在Pinia中加载完成我的做法是在路由守卫中await getInfo()之后再做整页渲染。4.4 Axios拦截与401处理前端的axios拦截器负责两件事请求时自动携带Token响应时统一处理异常特别是未登录和权限不足。// utils/request.js import axios from axios; import { ElMessage, ElMessageBox } from element-plus; import router from /router; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器携带Token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理错误码 request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, error { const status error.response?.status; if (status 401) { // 登录过期清空本地信息回登录页 localStorage.removeItem(token); ElMessageBox.confirm(登录状态已过期请重新登录, 提示, { confirmButtonText: 去登录, showCancelButton: false }).then(() { router.push(/login); }); } else if (status 403) { ElMessage.error(没有权限执行此操作); } else { ElMessage.error(error.response?.data?.message || 网络异常); } return Promise.reject(error); } );这里我补充非常重要的一点前后端联调时的跨域和代理问题。开发环境下前端和后端往往不在同一个端口我用Vite的server.proxy做代理让前端请求走/api前缀由Vite把请求转发到后端这样浏览器的同源策略就不会产生跨域问题。// vite.config.js server: { port: 80, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }5. 前后端联调与常见问题排查实录5.1 一次完整请求的权限校验链路当用户点击“新增用户”按钮整个RBAC权限链路是这样串联的把这一小节看清楚对权限体系的整体认知就会非常清晰。第一步前端按钮执行v-permission指令校验判断system:user:add是否在当前用户的权限列表中没有则按钮根本不会渲染。第二步用户点击按钮axios请求拦截器自动在请求头中加入Authorization: Bearer xxx。第三步请求到达后端JwtAuthenticationTokenFilter先执行解析JWT拿到用户ID从Redis中取出登录用户和权限列表放入SecurityContextHolder。第四步Spring Security的FilterSecurityInterceptor检查请求对应的URL发现/system/user接口需要认证此时上下文里有用户信息认证通过。第五步接口方法上的PreAuthorize(hasAuthority(system:user:add))执行Spring Security遍历当前用户的权限列表存在该权限则放行否则抛出AccessDeniedException。第六步全局异常处理器捕获AccessDeniedException返回403状态码前端响应拦截器收到403弹出“没有权限执行此操作”的提示。这样一次完整的请求就只调用了一个接口但权限校验从数据库到前端DOMAcross了好几层。任何一个环节没接上都会出现“前端有按钮但接口401”或者“接口能调通但前端没入口”的莫名其妙问题。5.2 高频踩坑记录与解决方案在实际开发和联调过程中我整理了7个高频问题都是团队前后端成员反复踩过的坑。问题现象根因分析解决方案接口返回401明明登录成功了请求头没有带Token或Token前缀不对确认请求头格式为Authorization: Bearer token。前缀必须是Bearer加空格大小写敏感SpEL表达式需加hasAuthority()判断修改角色权限后用户权限没变化登录用户和权限存了Redis没有主动刷新在角色授权变更接口中删除对应login:缓存或要求用户重新登录。生产环境更推荐用Redis的keys login:*匹配批量删除BCrypt加密的密码用比较失败BCrypt每次生成的哈希值随机不能直接字符串比较使用BCryptPasswordEncoder.matches(rawPassword, encodedPassword)比较PreAuthorize注解完全不生效忘了加EnableGlobalMethodSecurity在SecurityConfig上添加EnableGlobalMethodSecurity(prePostEnabled true)前端刷新后菜单变空白或404动态路由是内存中的刷新会丢失在路由守卫中检测到没有递归完成路由加载时重建使用return next({...to, replace: true})权限数据查出来有重复菜单多角色关联了同一个权限JOIN产生重复行查询SQL加DISTINCT这里面第3个问题最多人踩。有些初学者存密码时拿BCrypt加密验证时直接取数据库里的哈希值和用户输入的密码做字符串比较结果发现同一个密码两次加密的结果完全不同就怀疑代码写错了。实际上BCrypt内置随机盐每一次加密生成的串都不一样所以必须用matches方法做校验这是Spring Security的经典设计不是Bug。Redis缓存刷新这个问题在项目上线后尤其值得重视。我曾经遇到运维反馈“改了用户权限用户一刷新页面还是老的”排查了半天才发现角色分配权限的接口只写数据库没清缓存。后来在角色授权接口里统一加了清缓存逻辑才彻底解决。5.3 权限体系的扩展方向RBAC0模型虽然简单但实际项目中往往需要扩展这里分享几个我在真实业务里用过的扩展思路。第一个是组织架构。很多系统的用户数量过万按角色授权还是太粗需要引入“部门”维度。这时可以在RBAC0的基础上加一张sys_dept表用户归属部门部门绑定角色查询权限时既要查用户直接关联的角色也要查部门带来的角色。权限判断变成“用户角色权限 部门角色权限”数据权限上还能衍生出“只能看本部门数据”这类行级权限控制。第二个是数据权限。RBAC本身解决的是“能不能访问这个接口”的问题但“能看哪几条数据”是另一个维度。比如同样是查看订单接口销售A只能看自己的订单销售经理能看整个部门的订单。实现方式是在权限标识之外增加数据范围字段常见的有data_scope取值全部、本部门及以下、本部门、仅本人、自定义。做数据权限比做接口权限复杂得多需要结合具体业务在SQL层面注入过滤条件。第三个是超级管理员。实际项目里顶级账号通常不走权限表直接通过role_code admin判断放行所有操作。这样做的好处是超级管理员不会被误修改权限导致系统失控坏处是权限逻辑里到处要写if (isAdmin)的特判。我的建议是在接口权限注解之外统一封装一个RequiresPermission自定义注解内部判断管理员直接放行避免在业务代码里散落if判断。第四个是动态权限刷新。大型系统里权限变更往往希望立即生效除了清Redis缓存之外还可以用Redis的发布订阅或者消息队列通知所有服务节点刷新权限缓存。单机部署时清缓存就够了集群部署时要注意缓存的一致性问题。结尾这个RBAC权限管理系统做完之后我最大的体会是权限设计最难的其实不是写代码而是想清楚“谁在什么条件下能做什么”。模型选型、表结构设计、认证授权链路的每一层都是为了回答这个问题的不同侧面。回过头看整个项目从5张表到Spring Security的过滤器链再到Vue的动态路由和按钮指令每一层都有值得反复推敲的细节。如果你正在做类似的权限系统建议不要急着抄代码先把自己的用户、角色、权限关系画一画理一理再动手写SQL会顺利得多。文章里的完整代码和SQL都是我可直接运行的版本直接拿去改就行踩坑的部分我都标注出来了。最后再分享一个小技巧把这个项目作为面试的亮点时不要只讲“我做了RBAC”要抓住你最熟的一个点往深聊比如我会讲清楚JWT过滤器在Spring Security过滤器链中的位置以及权限变更如何解决缓存一致性问题。一个细节聊透比罗列十个功能更有说服力。
返回列表