做后台管理系统时,权限校验几乎是绕不开的一环。想象一个场景:普通用户通过修改浏览器地址栏里的URL,直接访问到了管理员的删除接口,数据瞬间被清空。这类事故的根源就是接口没有做权限控制。本文将用Java实现一套简单但完整的权限校验逻辑,覆盖权限模型设计、注解定义、拦截器实现和异常处理,代码可以直接跑在Spring Boot项目中。

权限模型设计:用户、角色、权限三张表
经典的权限模型是RBAC(基于角色的访问控制),核心思路是用户不直接持有权限,而是通过角色间接获得。一个用户可以拥有多个角色,一个角色可以包含多个权限,这样就形成了用户表、角色表、权限表,再加两张关联表的结构。
设计成三张表的好处是维护成本低。比如财务部有20个人,当他们的权限需要调整时,只需要修改“财务角色”的权限集合,20个用户自动生效,不用逐个修改用户记录。如果让用户直接绑定权限,每次调整都是一场灾难。
简化的表结构如下,实际项目中可以按需扩展字段:
-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL
);
-- 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(50) NOT NULL COMMENT '角色编码,如 ADMIN'
);
-- 用户角色关联表
CREATE TABLE sys_user_role (
user_id BIGINT,
role_id BIGINT
);
-- 权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
perm_code VARCHAR(100) NOT NULL COMMENT '权限编码,如 user:delete'
);
-- 角色权限关联表
CREATE TABLE sys_role_permission (
role_id BIGINT,
permission_id BIGINT
);权限编码建议采用“模块:操作”的命名方式,比如user:delete、order:export,一眼就能看出这个权限控制的是什么资源,后期排查问题时也方便在代码里全局搜索。
自定义注解:让权限声明写在接口上
权限校验的第一步是让每个接口能声明自己需要什么权限。最优雅的方式是自定义一个注解,比如@RequirePermission,然后直接标注在Controller方法上。这样接口需要的权限一目了然,比在代码里写一堆if判断干净得多。
注解的定义非常简单,注意要指定RetentionPolicy.RUNTIME,否则运行期通过反射拿不到注解信息:
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
/**
* 需要的权限编码,支持传多个,默认只要满足其中一个即可
*/
String[] value();
/**
* 多个权限之间的关系,AND表示全部满足,OR表示满足其一即可
*/
Logical logical() default Logical.OR;
}
enum Logical {
AND, OR
}使用的时候只需要一行代码,Controller保持清爽:
@RestController
@RequestMapping("/user")
public class UserController {
@RequirePermission("user:delete")
@DeleteMapping("/{id}")
public Result deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return Result.success();
}
// 需要同时拥有两个权限才能操作
@RequirePermission(value = {"order:export", "order:view"}, logical = Logical.AND)
@GetMapping("/export")
public Result exportOrders() {
return Result.success(orderService.export());
}
}如果整个类都需要同一套权限,也可以在@Target里加上ElementType.TYPE,让注解支持标注在类上,拦截器解析时先查方法上的注解,没有再查类上的注解,实现继承效果。
拦截器实现:核心校验逻辑落地
注解只是声明,真正干活的是拦截器。拦截器会在请求进入Controller之前执行,我们从请求映射到的方法上反射读取@RequirePermission注解,然后对比当前登录用户拥有的权限集合,不满足就直接拒绝。
完整实现如下,假设登录后用户权限已经放入Token或Session中:
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Set;
public class PermissionInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 静态资源等非Controller请求直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequirePermission annotation =
handlerMethod.getMethodAnnotation(RequirePermission.class);
// 没有注解说明接口不需要权限,放行
if (annotation == null) {
return true;
}
// 从会话中取出当前用户的权限集合(登录时写入)
Set<String> userPerms =
(Set<String>) request.getSession().getAttribute("userPerms");
if (userPerms == null || userPerms.isEmpty()) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或权限为空\"}");
return false;
}
String[] required = annotation.value();
boolean pass;
if (annotation.logical() == Logical.AND) {
// 全部满足
pass = userPerms.containsAll(Arrays.asList(required));
} else {
// 满足其一即可
pass = Arrays.stream(required).anyMatch(userPerms::contains);
}
if (!pass) {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}");
}
return pass;
}
}写完拦截器别忘了注册,否则Spring根本不知道它的存在。注册代码要放在配置类中,并配置排除路径,比如登录接口本身就不能被拦截:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new PermissionInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login", "/register", "/error");
}
}有一点需要特别注意:拦截器抛出的异常和直接写回JSON响应是两种风格,上面代码选择直接写响应是为了演示简单,规范一点的做法是抛出自定义的PermissionDeniedException,再通过全局异常处理器统一返回,这样前端拿到的错误格式才和业务异常保持一致。
统一异常处理与登录时加载权限
权限数据应该在登录成功后一次性查出来放入会话,而不是每次请求都查数据库。登录逻辑大致是:校验用户名密码,查询用户的所有角色,再汇总所有角色的权限编码,去重后存入Session。
@Service
public class LoginService {
@Autowired
private UserMapper userMapper;
public void login(String username, String password, HttpSession session) {
SysUser user = userMapper.selectByUsername(username);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new BizException("用户名或密码错误");
}
// 一次性查出该用户的所有权限编码
Set<String> perms = userMapper.selectPermissionsByUserId(user.getId());
session.setAttribute("userPerms", perms);
}
}对应的SQL用三表联查加上DISTINCT去重:
SELECT DISTINCT p.perm_code
FROM sys_user_role ur
JOIN sys_role_permission rp ON ur.role_id = rp.role_id
JOIN sys_permission p ON rp.permission_id = p.id
WHERE ur.user_id = #{userId}再配一个全局异常处理器,把403场景的返回结构统一掉:
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PermissionDeniedException.class)
public Result handlePermissionDenied(PermissionDeniedException e) {
return Result.fail(403, "权限不足:" + e.getMessage());
}
}方案对比与注意事项
除了拦截器方案,还有两种常见做法。一种是Servlet Filter,它执行时机比拦截器更早,连静态资源请求都会经过,适合做登录状态校验这类粗粒度控制,但Filter里拿不到HandlerMethod,无法方便地读取方法上的注解,做接口级权限比较别扭。另一种是AOP切面,功能上和拦截器类似,也能解析注解,适合需要在权限校验前后附加逻辑的场景,比如记录操作日志。对于普通单体项目,拦截器是最平衡的选择。
几个容易踩的坑:一是管理员后台修改了角色权限后,已登录用户的Session里还是旧权限,需要在改权限时清掉相关用户的会话,或者把权限缓存到Redis并设置合理过期时间;二是注解只写在了类上没写在方法上时,记得用AnnotatedElementUtils.findMergedAnnotation处理合并注解;三是前后端分离项目用Token替代Session后,拦截器里改为从请求头解析Token再查Redis即可,整体结构不用变。
本文实现的权限校验逻辑不到两百行代码,但覆盖了模型设计、注解声明、拦截执行、异常兜底的完整链路,稍加扩展就能支持按钮级权限和数据权限,足够应付大部分课程设计和中小型管理系统的需求。