背景与问题
做后台管理系统时,权限几乎绕不过去。页面菜单要不要显示、按钮能不能点、接口能不能调、某个角色能不能管理某些用户——这些问题如果一开始没设计清楚,后面大概率会变成“加一个需求改三层代码”。
我见过不少项目在早期为了赶进度,直接在代码里写:
if ("ADMIN".equals(user.getRoleCode())) {
// 允许操作
}
前期确实快,但系统一旦从“一个管理员”演变成“运营、财务、审核员、超级管理员、部门主管”等多个角色,这种写法会很快失控:
- 角色判断散落在 Controller、Service 甚至前端
- 菜单权限和接口权限不是一套标准
- 新增角色必须改代码,不是配数据
- 权限变更后无法审计,也难以排查越权问题
- 接口防护只做了前端隐藏,后端实际上裸奔
所以,这篇文章我不打算只讲“RBAC 是什么”,而是带你从后台权限管理系统的架构视角,把一套基于 Spring Boot + MyBatis 的权限方案搭起来,并落到接口鉴权可运行代码上。
本文重点解决三个问题:
- 权限模型怎么设计才不容易烂尾
- 菜单、按钮、接口权限怎么统一建模
- Spring Boot 项目里怎么把鉴权真正拦到接口入口
方案概览与取舍分析
后台权限系统最常见的几种思路:
1. 直接写死角色判断
优点:
- 上手快
- 小项目初期成本低
缺点:
- 扩展性差
- 新角色上线要改代码
- 容易遗漏接口校验
适用边界:
- 只有 1~2 种角色
- 生命周期很短的内部工具
2. 基于 RBAC 的权限控制
RBAC(Role-Based Access Control)核心思想:用户不直接绑定大量权限,而是通过角色间接拥有权限。
优点:
- 角色复用
- 权限配置化
- 适合后台系统
缺点:
- 数据模型比“写死角色”复杂
- 若颗粒度太细,运维成本会上升
适用边界:
- 中后台管理系统
- 多角色、多菜单、多接口的项目
3. RBAC + 数据范围
仅有“能不能访问接口”还不够,很多系统还要求“能访问,但只能看自己部门数据”。
优点:
- 更贴近企业场景
缺点:
- 复杂度明显上升
- 需要结合组织架构、部门树、业务数据设计
这篇文章主线采用:RBAC 为主,预留数据范围扩展点。
这是我比较推荐的落地方式:先把“功能权限”做扎实,再演进“数据权限”。
核心原理
一、RBAC 的最小可落地模型
最常见的 5 张核心表:
- 用户表
sys_user - 角色表
sys_role - 权限表
sys_permission - 用户角色关系表
sys_user_role - 角色权限关系表
sys_role_permission
权限本身建议统一抽象,不要拆成“菜单表、按钮表、接口表”三套割裂结构。更实用的做法是:权限一张表,通过 type 区分菜单、按钮、接口。
二、统一权限模型的关键
一个后台系统里,权限至少有三类:
- 菜单权限:控制导航是否可见
- 按钮权限:控制页面上的操作按钮
- 接口权限:控制后端 API 是否允许调用
如果菜单归菜单、接口归接口,最后很容易出现:
- 前端按钮隐藏了,但接口没拦
- 后端接口加了权限码,但前端查不到对应按钮配置
- 权限配置平台维护成本高
因此,我一般建议权限表这样设计:
| 字段 | 含义 |
|---|---|
| id | 主键 |
| parent_id | 父节点 |
| name | 权限名称 |
| code | 权限唯一编码,如 system:user:list |
| type | MENU / BUTTON / API |
| path | 菜单路由或接口路径 |
| method | HTTP 方法,接口权限时使用 |
| sort | 排序 |
| status | 状态 |
核心思想是:前端看菜单树,后端看权限码,接口再结合 path + method 做二次匹配。
三、从登录到鉴权的执行链路
下面这张图可以帮助你把整体链路串起来。
flowchart TD
A[用户登录] --> B[校验用户名密码]
B --> C[查询用户角色]
C --> D[聚合角色下权限]
D --> E[生成登录态 Session/JWT]
E --> F[请求访问接口]
F --> G[认证拦截 识别用户]
G --> H[鉴权拦截 提取接口所需权限]
H --> I{是否具备权限}
I -- 是 --> J[进入 Controller/Service]
I -- 否 --> K[返回 403 Forbidden]
这条链路里有两个阶段一定要分清:
- 认证 Authentication:你是谁
- 授权 Authorization:你能干什么
很多系统把这两个概念混在一起,最后调试时非常痛苦。比如用户明明登录成功了,但还是 403,这不是认证失败,是授权失败。
四、权限校验的两种主流实现
方案 A:注解式权限校验
例如:
@RequiresPermission("system:user:list")
@GetMapping("/users")
public List<UserDTO> list() { ... }
优点:
- 可读性高
- 权限和接口定义放在一起
- 适合大多数业务接口
缺点:
- 需要开发者自觉标注
- 容易漏标
方案 B:基于请求路径 + 方法自动匹配
例如把 GET /api/users 映射到某条 API 权限记录。
优点:
- 配置化强
- 可以减少开发者心智负担
缺点:
- 实现复杂
- RESTful 路径变量、模糊匹配处理麻烦
我的建议:
实际项目中采用混合模式最稳妥:
- 核心业务接口:注解显式声明权限码
- 配置中心/审计系统:保留 path + method 作为运营排查依据
- 未标注接口:默认拒绝或纳入白名单
数据库设计
下面给出一套可直接落地的 SQL。
1. 建表 SQL
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
nickname VARCHAR(64),
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(64) NOT NULL,
role_code VARCHAR(64) NOT NULL UNIQUE,
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT 0,
name VARCHAR(64) NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
type VARCHAR(16) NOT NULL,
path VARCHAR(200),
method VARCHAR(16),
sort INT DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role(user_id, role_id)
);
CREATE TABLE sys_role_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
UNIQUE KEY uk_role_permission(role_id, permission_id)
);
2. 初始化数据
INSERT INTO sys_user (username, password, nickname, status)
VALUES ('admin', '$2a$10$7sWkQmWbY5x0nWn1zQH7r.vQ7M4rGzD2Z4wW7P6f0B8Yx3dIYJ1s2', '超级管理员', 1);
INSERT INTO sys_role (role_name, role_code, status)
VALUES ('超级管理员', 'SUPER_ADMIN', 1),
('运营', 'OPERATOR', 1);
INSERT INTO sys_permission (parent_id, name, code, type, path, method, sort, status)
VALUES
(0, '用户管理', 'system:user', 'MENU', '/system/user', NULL, 1, 1),
(1, '用户列表', 'system:user:list', 'BUTTON', NULL, NULL, 1, 1),
(1, '新增用户', 'system:user:add', 'BUTTON', NULL, NULL, 2, 1),
(0, '查询用户接口', 'api:user:list', 'API', '/api/users', 'GET', 1, 1),
(0, '新增用户接口', 'api:user:add', 'API', '/api/users', 'POST', 2, 1);
INSERT INTO sys_user_role (user_id, role_id)
VALUES (1, 1);
INSERT INTO sys_role_permission (role_id, permission_id)
VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5);
说明:这里密码是 BCrypt 示例值,实际项目请自己重新生成。
权限模型类图
这张图可以帮助你理解核心实体之间的关系。
classDiagram
class SysUser {
Long id
String username
String password
Integer status
}
class SysRole {
Long id
String roleName
String roleCode
Integer status
}
class SysPermission {
Long id
Long parentId
String name
String code
String type
String path
String method
Integer status
}
class SysUserRole {
Long userId
Long roleId
}
class SysRolePermission {
Long roleId
Long permissionId
}
SysUser --> SysUserRole
SysRole --> SysUserRole
SysRole --> SysRolePermission
SysPermission --> SysRolePermission
实战代码(可运行)
下面给出一个简化但能跑通思路的 Spring Boot + MyBatis 实现。
一、项目结构建议
src/main/java
├── controller
├── service
├── mapper
├── model
├── security
│ ├── LoginUser.java
│ ├── SecurityContext.java
│ ├── AuthInterceptor.java
│ ├── RequiresPermission.java
│ └── PermissionEvaluator.java
└── config
└── WebMvcConfig.java
二、Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
三、配置文件
spring:
datasource:
url: jdbc:mysql://localhost:3306/rbac_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.example.rbac.model
server:
port: 8080
四、实体类
SysUser.java
package com.example.rbac.model;
public class SysUser {
private Long id;
private String username;
private String password;
private String nickname;
private Integer status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
SysPermission.java
package com.example.rbac.model;
public class SysPermission {
private Long id;
private Long parentId;
private String name;
private String code;
private String type;
private String path;
private String method;
private Integer status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getParentId() {
return parentId;
}
public void setParentId(Long parentId) {
this.parentId = parentId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
五、Mapper 层
UserMapper.java
package com.example.rbac.mapper;
import com.example.rbac.model.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
SysUser findByUsername(@Param("username") String username);
}
PermissionMapper.java
package com.example.rbac.mapper;
import com.example.rbac.model.SysPermission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PermissionMapper {
List<SysPermission> findByUserId(@Param("userId") Long userId);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.rbac.mapper.UserMapper">
<select id="findByUsername" resultType="com.example.rbac.model.SysUser">
select id, username, password, nickname, status
from sys_user
where username = #{username} and status = 1
</select>
</mapper>
PermissionMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.rbac.mapper.PermissionMapper">
<select id="findByUserId" resultType="com.example.rbac.model.SysPermission">
select distinct p.id, p.parent_id, p.name, p.code, p.type, p.path, p.method, p.status
from sys_permission p
inner join sys_role_permission rp on p.id = rp.permission_id
inner join sys_user_role ur on rp.role_id = ur.role_id
where ur.user_id = #{userId}
and p.status = 1
</select>
</mapper>
六、登录用户上下文
LoginUser.java
package com.example.rbac.security;
import java.util.Set;
public class LoginUser {
private Long userId;
private String username;
private Set<String> permissions;
public LoginUser(Long userId, String username, Set<String> permissions) {
this.userId = userId;
this.username = username;
this.permissions = permissions;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Set<String> getPermissions() {
return permissions;
}
}
SecurityContext.java
package com.example.rbac.security;
public class SecurityContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
public static void set(LoginUser loginUser) {
HOLDER.set(loginUser);
}
public static LoginUser get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
七、自定义权限注解
RequiresPermission.java
package com.example.rbac.security;
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequiresPermission {
String value();
}
八、权限校验器
PermissionEvaluator.java
package com.example.rbac.security;
import org.springframework.stereotype.Component;
@Component
public class PermissionEvaluator {
public boolean hasPermission(String permissionCode) {
LoginUser loginUser = SecurityContext.get();
if (loginUser == null) {
return false;
}
return loginUser.getPermissions() != null
&& loginUser.getPermissions().contains(permissionCode);
}
}
九、拦截器实现接口鉴权
这里我用 Spring MVC 的 HandlerInterceptor 做一个轻量实现,足够演示落地过程。
AuthInterceptor.java
package com.example.rbac.security;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final PermissionEvaluator permissionEvaluator;
public AuthInterceptor(PermissionEvaluator permissionEvaluator) {
this.permissionEvaluator = permissionEvaluator;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequiresPermission requiresPermission = handlerMethod.getMethodAnnotation(RequiresPermission.class);
if (requiresPermission == null) {
return true;
}
if (!permissionEvaluator.hasPermission(requiresPermission.value())) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
SecurityContext.clear();
}
}
十、注册拦截器
WebMvcConfig.java
package com.example.rbac.config;
import com.example.rbac.security.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebMvcConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/login");
}
}
十一、登录服务
为了让示例可以跑起来,这里用一个简化版 Header 模拟登录态。
真实项目中你应该替换成 Session、JWT 或 Spring Security 认证链。
AuthService.java
package com.example.rbac.service;
import com.example.rbac.mapper.PermissionMapper;
import com.example.rbac.mapper.UserMapper;
import com.example.rbac.model.SysPermission;
import com.example.rbac.model.SysUser;
import com.example.rbac.security.LoginUser;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AuthService {
private final UserMapper userMapper;
private final PermissionMapper permissionMapper;
public AuthService(UserMapper userMapper, PermissionMapper permissionMapper) {
this.userMapper = userMapper;
this.permissionMapper = permissionMapper;
}
public LoginUser login(String username, String password) {
SysUser user = userMapper.findByUsername(username);
if (user == null) {
return null;
}
// 示例简化:直接明文比较,生产环境必须使用 BCrypt
if (!user.getPassword().equals(password)) {
return null;
}
List<SysPermission> permissions = permissionMapper.findByUserId(user.getId());
Set<String> permissionCodes = permissions.stream()
.map(SysPermission::getCode)
.collect(Collectors.toSet());
return new LoginUser(user.getId(), user.getUsername(), permissionCodes);
}
public LoginUser loadUser(Long userId, String username) {
List<SysPermission> permissions = permissionMapper.findByUserId(userId);
Set<String> permissionCodes = permissions.stream()
.map(SysPermission::getCode)
.collect(Collectors.toSet());
return new LoginUser(userId, username, permissionCodes);
}
}
十二、认证过滤逻辑(示例版)
这里为了尽量少引入框架复杂度,演示一种“从请求头中取用户信息”的简化方式。
实际生产里推荐换成 JWT Filter 或 Spring Security。
MockLoginInterceptor.java
package com.example.rbac.security;
import com.example.rbac.service.AuthService;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class MockLoginInterceptor implements HandlerInterceptor {
private final AuthService authService;
public MockLoginInterceptor(AuthService authService) {
this.authService = authService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String userId = request.getHeader("X-USER-ID");
String username = request.getHeader("X-USERNAME");
if (userId != null && username != null) {
LoginUser loginUser = authService.loadUser(Long.valueOf(userId), username);
SecurityContext.set(loginUser);
}
return true;
}
}
更新 WebMvcConfig.java
package com.example.rbac.config;
import com.example.rbac.security.AuthInterceptor;
import com.example.rbac.security.MockLoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final MockLoginInterceptor mockLoginInterceptor;
public WebMvcConfig(AuthInterceptor authInterceptor, MockLoginInterceptor mockLoginInterceptor) {
this.authInterceptor = authInterceptor;
this.mockLoginInterceptor = mockLoginInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(mockLoginInterceptor).addPathPatterns("/api/**");
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/login");
}
}
十三、Controller 示例
UserController.java
package com.example.rbac.controller;
import com.example.rbac.security.RequiresPermission;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
@RequiresPermission("api:user:list")
@GetMapping
public List<Map<String, Object>> list() {
return Arrays.asList(
Map.of("id", 1, "username", "admin"),
Map.of("id", 2, "username", "operator")
);
}
@RequiresPermission("api:user:add")
@PostMapping
public Map<String, Object> add(@RequestBody Map<String, Object> body) {
return Map.of("message", "新增成功", "data", body);
}
}
十四、接口调用顺序图
这个过程最好结合时序图看一眼。
sequenceDiagram
participant Client as 客户端
participant Mock as MockLoginInterceptor
participant Auth as AuthInterceptor
participant Controller as UserController
participant DB as MySQL
Client->>Mock: 请求 /api/users + Header
Mock->>DB: 根据 userId 查询权限
DB-->>Mock: 返回权限集合
Mock->>Auth: 放入 SecurityContext
Auth->>Controller: 读取 @RequiresPermission
Auth->>Auth: 校验权限码
alt 有权限
Auth-->>Client: 放行,返回业务数据
else 无权限
Auth-->>Client: 403 Forbidden
end
逐步验证
你可以按下面步骤快速验证系统是否跑通。
1. 启动应用
确保数据库表和数据已初始化。
2. 请求用户列表接口
curl -X GET "http://localhost:8080/api/users" \
-H "X-USER-ID: 1" \
-H "X-USERNAME: admin"
预期返回:
[
{"id":1,"username":"admin"},
{"id":2,"username":"operator"}
]
3. 请求新增用户接口
curl -X POST "http://localhost:8080/api/users" \
-H "Content-Type: application/json" \
-H "X-USER-ID: 1" \
-H "X-USERNAME: admin" \
-d '{"username":"test"}'
预期返回:
{"message":"新增成功","data":{"username":"test"}}
4. 模拟无权限用户
如果你创建一个普通角色,只赋予列表权限、不赋予新增权限,那么调用 POST 接口时应返回:
{"code":403,"message":"无权限访问"}
常见坑与排查
权限系统最烦的地方不是“不会写”,而是“看起来都对,但就是不生效”。下面这些坑我基本都踩过。
1. 前端隐藏了按钮,但后端接口没拦
现象:
- 页面上看不到“删除”按钮
- 但别人直接抓包调用接口仍然成功
原因:
- 把“按钮可见性”误当成“安全控制”
排查建议:
- 检查每个敏感接口是否都做了服务端权限校验
- 前端权限只能作为体验优化,不能替代后端鉴权
2. 用户权限改了,但立刻不生效
现象:
- 管理员刚给用户加权限
- 用户刷新页面仍然提示 403
可能原因:
- 权限被缓存到 Session 或 Redis 中
- JWT 内部携带了旧权限快照
处理方式:
- 提供“踢下线/强制刷新权限”机制
- 对高敏感系统,权限变更后主动使旧 token 失效
- 对低频变更系统,可接受短时间缓存延迟,但必须明确 SLA
3. 注解写了,但拦截器没生效
常见原因:
- 拦截路径没覆盖到接口
- 接口不是
HandlerMethod - 方法代理层级导致注解读取位置不对
- 注册顺序错误
排查步骤:
- 确认
addPathPatterns("/api/**")是否匹配实际接口 - 在
preHandle里打日志看是否进入 - 输出
handler.getClass()判断是不是方法处理器 - 检查接口是不是被网关转发后路径变化了
4. MyBatis 查出来权限重复
现象:
- 一个用户权限集合里同一个 code 出现多次
原因:
- 角色和权限是多对多,联表后天然可能重复
解决方式:
- SQL 中加
distinct - Java 侧转
Set<String>
5. ThreadLocal 没清理导致串用户
这个坑非常隐蔽。
现象:
- 某些请求偶发地拿到别人的登录信息
原因:
- 使用线程池时,
ThreadLocal数据没在请求结束清理
解决方式:
- 一定在
afterCompletion中SecurityContext.clear() - 如果换成异步任务,还要考虑上下文传递与隔离
安全/性能最佳实践
权限系统是安全功能,但它也会成为性能热点。这里分开说。
一、安全最佳实践
1. 密码必须加密存储
示例代码里为了突出权限主线,用了明文比较,这是故意简化。
生产环境必须使用 BCryptPasswordEncoder 或类似方案。
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordDemo {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String encoded = encoder.encode("123456");
System.out.println(encoded);
System.out.println(encoder.matches("123456", encoded));
}
}
2. 默认拒绝,白名单放行
权限系统最怕“忘了配,结果自动放过”。
建议策略:
- 登录接口、健康检查接口白名单
- 其余业务接口默认要求认证
- 敏感接口默认必须显式声明权限
3. 权限码命名要稳定
建议统一风格:
模块:资源:动作
system:user:list
system:user:add
system:role:assign
不要今天叫 user:list,明天改成 sysUserQuery。权限码一旦被前后端、日志、审计系统依赖,频繁改名代价很高。
4. 记录审计日志
至少要记录:
- 谁访问了什么接口
- 请求时间
- 是否鉴权成功
- 被拒绝原因
- 关键操作前后参数摘要
特别是角色授权、权限变更、用户启停这类操作,审计日志很重要。
二、性能最佳实践
1. 不要每次请求都全量查库
如果每个接口都要联 5 张表查一遍权限,系统吞吐会很差。
常见优化方式:
- 登录时加载权限,缓存到 Session/JWT/Redis
- 用户权限变更时主动失效缓存
- 本地缓存 + Redis 二级缓存
2. 权限集合结构用 Set
鉴权本质是“某个 code 是否存在”,所以用 Set<String> 最直接,时间复杂度更优。
3. 菜单树和接口权限分开组装
前端菜单通常需要树形结构,接口鉴权只需要平铺权限码集合。
不要为了菜单展示每次都构造整棵树,然后再去做接口判断,这会浪费不少 CPU。
4. 热点接口避免过多反射开销
注解鉴权通常要反射读取方法元信息。大多数系统问题不大,但如果你的 QPS 很高,可以缓存:
Method -> requiredPermissionrequestMapping -> permissionCode
容量估算与演进建议
对于中型后台系统,可以先按下面的量级做粗估:
- 用户数:1 万以内
- 角色数:几十到几百
- 权限点:几百到上千
- 单用户权限数:几十到几百
在这个规模下:
- MySQL 存关系表完全够用
- Redis 做权限缓存足够
- Spring MVC 拦截器 + 自定义注解也够稳定
当系统继续增长时,可以考虑如下演进:
阶段 1:单体应用内置权限模块
适合:
- 单项目后台
- 角色体系简单
做法:
- 权限表直接和业务系统在同库
- 应用内完成鉴权
阶段 2:统一认证中心 + 多业务系统复用
适合:
- 多个后台系统
- 需要统一登录、统一角色管理
做法:
- 认证中心发 token
- 各业务系统消费用户权限
- 权限配置平台独立
阶段 3:加入数据权限与组织架构
适合:
- 企业级管理平台
- 需要“只能看本部门、本区域、本人的数据”
做法:
- 在 RBAC 之外增加数据范围表达式
- Service/SQL 层增加数据过滤能力
- 与部门树、岗位体系联动
我的经验是:不要一上来就把系统设计成“宇宙级权限平台”。
先把角色、菜单、接口三件事做稳定,再考虑数据权限、租户隔离、审批联动。
总结
如果要把后台权限系统真正做“能上线、能维护、能扩展”,我建议你抓住这几个核心点:
- 用 RBAC 做主模型,别把角色判断写死在业务代码里
- 统一菜单、按钮、接口权限模型,避免三套规则互相打架
- 后端接口必须兜底鉴权,前端隐藏按钮不等于安全
- 注解鉴权 + 权限缓存 是一条非常实用的落地路线
- 预留数据权限扩展点,但不要过早复杂化
如果你当前项目还在“Controller 里 if 判断角色”的阶段,可以先做这三步最小改造:
- 抽出
sys_permission、sys_role_permission关系 - 给关键接口加
@RequiresPermission - 在拦截器里统一做权限校验
这三步做完,系统的可维护性会立刻上一个台阶。
最后补一句很实在的话:
权限系统不是一次性写完就结束的模块,它会随着组织结构、业务边界、审计要求不断演化。所以设计时最重要的,不是“今天看起来多高级”,而是半年后加需求时,你还改得动。