接手后台管理系统多了,你会发现权限这块的坑几乎长得一模一样:前端把新增按钮藏起来就以为安全了,后端每个Service方法开头翻来覆去写 if ("admin".equals(...)),等到角色多起来,判断条件写成一长串不说,改一个角色的权限还得重新发版。这篇文章我会把我一直在用的一套组合方案完整梳理一遍——用 RBAC 做数据模型,用 SpringSecurity 做认证和登录态管理,再用自定义注解把接口权限声明收敛到一行代码上。三者各管一段,既不会把判断逻辑散落在业务代码里,也不会让后续加角色改权限变得畏手畏脚。如果你正打算给后台系统搭权限模块,或者想把现有那堆硬编码权限判断收拾干净,这篇应该可以作为一份落地参考。
1. 权限控制的乱象根源:藏在接口和业务代码里的硬编码
1.1 “前端隐藏按钮”不等于后端安全
很多项目第一个风险点,是把权限控制写在了页面上。用户登录后,前端根据角色对菜单和按钮做显隐控制,看起来用户看不到删除按钮,就不会删除数据。问题在于,前端隐藏只是体验优化,不是权限控制。只要用户已经登录,他发什么 HTTP 请求后端是拦不住的,删除接口如果只做登录校验,被从抓包记录里找到后手动调用一次,照样能把数据删掉。
要验证自己的系统是不是这样,方法很简单:用一个没有删除权限的普通账号登录,打开浏览器控制台,直接往删除接口发一个请求,看看能不能删成功。如果答案是能,说明接口层权限校验是缺失的。我之前处理过一个项目就是这种状态,菜单表、按钮权限表都建了,Controller 层却只判断了登录状态。后来一个不熟悉系统的运营,通过旧页面的请求记录调删除接口,把测试环境的配置误删了。没造成多大事故,但那次之后我给自己定了个原则:前端控制的是“能不能看到”,后端要控制的是“能不能做”,两者缺一不可,且后端必须兜底。
1.2 业务代码里到处是 isAdmin 判断
比接口裸奔更常见的,是每个 Service 方法开头都有这么一段:
java复制if (!"admin".equals(loginUser.getRoleCode())) {
throw new BusinessException("当前用户没有权限执行此操作");
}
需求简单的时候,这段代码没任何问题。但角色一旦多起来——除了管理员,还有运营、审核、财务、客服——你会发现不是所有操作都只有超管能做,而是运营能导出数据但不能删除,审核能通过订单但不能退款,财务能看金额但不能改金额。一个简单的 isAdmin 判断完全承载不了这种差异。
我见过最夸张的版本,一个方法里叠了三层角色判断:
java复制if (isAdmin || (isOp && !operation.equals("delete")) || (isAudit && operation.equals("view"))) {
// do something
}
这种代码改起来非常折磨人,因为你必须把每个角色在每个操作上的权限都读一遍才能下手。更头疼的是用户可能同时属于多个角色,只要逻辑稍微绕一点,极容易漏掉边界。走到这一步,问题已经不在某个 if 写得对不对,而在于系统根本没有把“权限”抽象成独立的数据模型。
1.3 痛点背后真正缺的是“权限模型”
把项目里反复出现的问题放在一起看,会发现本质不是代码写得不好,而是项目里没有一套清晰的权限模型。能不能访问某个菜单,能不能点某个按钮,能不能调用某个接口,这些事不应该散落在业务逻辑里反复判断,而应该被抽象成用户、角色、权限之间的数据关系。
RBAC 模型解决的就是这个问题。它把用户和权限中间加了一层角色,权限分配从“给每个用户单独授权”变成“把权限绑到角色上,再把用户放进角色”。拿门禁卡来类比:权限是一把把钥匙,角色是一个岗位钥匙包,用户是拿钥匙包的人。调整某人权限时,不需要一把把换钥匙,只需换掉整个钥匙包;调整一批同岗位员工的权限时,也只需要改角色对应的钥匙包。后面几个章节要做的,就是基于这套思路把数据表建出来,再把“当前用户是否具备当前操作的权限标识”这个校验逻辑收敛到统一位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RBAC 数据模型落地方案:五张表和一个权限标识约定
2.1 RBAC 如何映射到管理后台的真实场景
RBAC 落到管理后台,核心关系就三句话:一个用户可以有多个角色,一个角色可以有多项权限,用户通过角色间接获得权限。在做表结构前,先想清楚“权限”在这里表示什么。
后台系统里最常见的权限分两类:菜单权限和操作权限。菜单权限决定用户登录后左侧导航能看到哪些页面;操作权限决定页面上哪些按钮能点、哪些接口能调。这两类在数据库里不应该分开建两套混乱的表,而是统一放在一张权限表里,通过 type 字段区分,再用父节点 id 维护树形层级。菜单权限的父节点是上一级目录,按钮权限的父节点是它所属的菜单。
2.2 五张表结构设计与 SQL
标准 RBAC 落地需要五张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。用户表和权限表之间不直接关联,也不要去建“用户权限关联表”,那样又退回给每个用户单独授权的模式了。下面是精简过的核心结构。
sql复制-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
real_name VARCHAR(50),
status TINYINT DEFAULT 1 COMMENT '1正常 0停用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(50) NOT NULL,
role_code VARCHAR(50) NOT NULL UNIQUE,
status TINYINT DEFAULT 1,
remark VARCHAR(255)
);
-- 权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT 0 COMMENT '父节点id,菜单权限的上级是目录',
perm_name VARCHAR(50) NOT NULL COMMENT '菜单或按钮名称',
perm_code VARCHAR(100) NOT NULL COMMENT '权限标识,如 admin:user:add',
perm_type TINYINT NOT NULL COMMENT '1目录 2菜单 3按钮/接口',
sort INT DEFAULT 0,
status TINYINT DEFAULT 1
);
-- 用户角色关联表
CREATE TABLE sys_user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
-- 角色权限关联表
CREATE TABLE sys_role_permission (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
索引方面,两张关联表我都用了联合主键,查询某用户角色、某角色权限时直接走主键索引,不需要额外加索引。权限表里 parent_id 要单独建索引,因为渲染菜单树和按钮树时总要按父节点查子节点。
2.3 权限标识的编码:一段冒号背后的可维护性
权限表设计中最容易被忽略的是 perm_code 的编码规范。我强烈建议统一采用冒号分隔的三段式,例如 admin:user:add。第一段是模块名,第二段是资源名,第三段是操作名。看到这个字符串,不需要查数据库就知道是“后台管理的用户模块新增操作”。
三段式编码的优势在后端注解和前端按钮权限打通时体现得最明显。后端的接口权限注解写 @RequirePermission("admin:user:add"),前端按钮的权限指令也指向 admin:user:add,同一个字符串两处共用,前后端对照时不会有理解偏差。另外冒号分隔天然方便做通配或批量匹配,比如角色只需要“用户模块的所有操作”,可以允许一个角色配置 admin:user:* 这种通配权限,在做判断时做前缀匹配而不是全等匹配即可。
操作名建议固定用 add、edit、delete、export、import、audit、assign 这类标准化动词,不要一会儿中文一会儿英文,更不要把 URL 路径当 perm_code 用。URL 里带参数和不带参数的接口很多,拿 URL 当权限标识,一是容易漏匹配,二是没法表达“新增用户”这种语义化的操作。
2.4 登录成功后权限标识集合的组装与缓存策略
用户权限的查询链路是:用户 → 用户角色表 → 角色 → 角色权限表 → 权限表。一条多表关联能查出来,但要防止每次请求都查库。我的做法是登录成功后只查一次,把结果组装成权限标识集合放到 Redis 里,key 用 login:user:perms:{userId},value 就是一组权限代码。
加载权限的查询,核心 SQL 大概是这样的:
sql复制SELECT DISTINCT p.perm_code
FROM sys_user u
JOIN sys_user_role ur ON u.id = ur.user_id
JOIN sys_role r ON ur.role_id = r.id
JOIN sys_role_permission rp ON r.id = rp.role_id
JOIN sys_permission p ON rp.permission_id = p.id
WHERE u.id = #{userId}
AND u.status = 1
AND r.status = 1
AND p.status = 1
组装好 Set<String> 后,连同用户基本信息一起放进登录态。这个集合就是后面所有权限校验的数据源,接口注解上声明的是单个权限标识,切面里判断的就是当前用户这个集合是否包含对应标识。
3. SpringSecurity 在整套方案中解决了哪一段:认证交给它,授权交给切面
3.1 过滤器链视角:一次请求如何完成认证
SpringSecurity 的核心是一条过滤器链,请求进来后要经过一系列过滤器才会进入 Controller。和权限方案相关的关键节点有三个。SecurityContextHolderFilter 会先看当前请求是否已有认证信息;如果是账号密码登录,认证过滤器会取出用户名密码,组装成 AuthenticationToken 交给 AuthenticationManager 校验,校验成功后把完整的 Authentication 对象放进 SecurityContext;SecurityContext 默认存在 ThreadLocal 里,后续任何代码都能通过 SecurityContextHolder.getContext() 拿到当前登录人。
如果项目是前后端分离、接口走 JWT 的方式,通常不会用默认的登录过滤器,而是自定义一个 JwtAuthenticationTokenFilter,在过滤器链的某个位置解析 token、加载用户信息,然后手动把 Authentication 塞进 SecurityContextHolder。塞进去之后,后端的 Controller、Service、切面就都能拿到当前用户了。
3.2 SecurityFilterChain 配置中最容易被忽略的点
实际配置 SecurityFilterChain 时,最值得注意的就是放行顺序。SpringSecurity 的 URL 匹配规则是先匹配先生效,所以公共路径,比如登录接口、验证码接口、静态资源,一定要放在前面放行;受保护路径放在后面。顺序写反了,公共接口也会直接 403。
一个低风险项目的基础配置可以长这样:
java复制@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/captcha", "/doc/**").permitAll()
.requestMatchers("/admin/**").authenticated()
.anyRequest().authenticated())
.exceptionHandling(handler -> handler
.authenticationEntryPoint((request, response, ex) -> writeUnauthorized(response))
.accessDeniedHandler((request, response, ex) -> writeForbidden(response)));
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
这里有个细节:在 .anyRequest().authenticated() 的模式下,就算只做“登录了才能访问”的粗粒度控制,已经能挡掉一部分未登录的请求。但真正的细粒度功能权限还是要靠注解来约束,因为 SpringSecurity 并不知道“新增用户”这个操作需要哪种能力,它只认权限字符串本身。
3.3 为什么 SpringSecurity 自带方法注解还不够
SpringSecurity 自带的方法级权限注解用法很标准,在方法上写表达式即可:
java复制@PreAuthorize("hasAuthority('admin:user:add')")
public void addUser(User user) { ... }
这个方案本身没问题,有经验的团队完全可以用它。但我在实际项目中更推荐再包一层自定义注解,原因有几个。
第一,SpEL 表达式写多了之后,团队里不同人的风格很难统一。有人写 hasAuthority,有人写 hasAnyAuthority,还有人从工具类里取权限判断,代码 review 成本变高。自定义注解把表达式收敛成一个字符串参数,写法和阅读都非常直白。第二,自定义注解可以附带更多语义化配置,比如是否需要超级管理员、是否需要记录操作日志、是否需要做接口耗时监控。这些能力如果想通过 SpringSecurity 自带的注解去做,要么得额外叠加别的注解,要么就得写复杂的 SpEL。第三,安全校验逻辑如果散落在不同的表达式写法里,出问题后排查范围很大;收口到自己的切面里之后,规则只有一处,行为可预期。
所以我的建议是:SpringSecurity 专注做认证,把用户是谁搞清楚,权限校验收口到自定义注解的切面里。这条链路职责清晰,代码也更好维护。
3.4 认证上下文怎么传递给后面的自定义切面
自定义注解的切面执行时机在 Controller 方法调用之前,但它和 SpringSecurity 在同一个线程里,所以直接通过 SecurityContextHolder.getContext().getAuthentication() 就能拿到认证信息。这也是为什么前面强调登录信息必须封装好塞进 SecurityContext,切面只是从上下文里取用,而不需要关心用户具体怎么登录的。
如果某些业务要抛到异步线程里去执行,情况会变复杂。SpringSecurity 默认的 SecurityContext 存在 ThreadLocal 中,子线程默认拿不到父线程的值,这块在第 6 章踩坑部分再展开讲。单线程的常规请求链路中,上下文传递是透明且可靠的。
4. 自定义注解从 0 到 1:权限声明、AOP 拦截、异常处理
4.1 注解定义及设计取舍
自定义注解的定位是“权限声明”。它的作用是让开发者在一个方法或一个类上写明,访问这个接口需要什么权限。先看注解定义:
java复制package com.example.framework.security.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequirePermission {
/**
* 权限标识,例如 admin:user:add
*/
String value() default "";
/**
* 是否仅超级管理员可访问
*/
boolean adminOnly() default false;
}
@Target 里同时放 METHOD 和 TYPE 是有意设计的。大部分接口的权限粒度是方法级的,一个新增用户接口对应一个权限标识;但有些 Controller 是整类接口都要求属于某个模块,此时允许在类上写一个公共权限,类里面所有方法默认继承。方法上如果单独写了注解,就以方法为准,提供覆盖能力。
value() 设计成默认空字符串是方便在类上标注时复用。使用方式是接口上直接写权限标识,切面拿字符串和当前用户的权限集合对比。adminOnly 字段给特定接口使用,比如删除角色的操作要求管理员,即使某用户拥有角色删除权限,只要不是管理员也会被拦下。
4.2 拦截切面的完整实现
权限校验的核心切面如下。两个切点方法分别处理方法级注解和类级注解,类级判断时要注意方法上如果已有注解就跳过,避免重复校验。
java复制package com.example.framework.security.aspect;
import com.example.framework.security.annotation.RequirePermission;
import com.example.framework.security.exception.NotLoginException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionService permissionService;
/**
* 方法上标注 @RequirePermission 时的校验入口
*/
@Before("@annotation(permission)")
public void checkMethodPermission(JoinPoint joinPoint, RequirePermission permission) {
if (!validate(permission)) {
throw new AccessDeniedException("无权限访问该接口");
}
}
/**
* 类上标注 @RequirePermission 时的校验入口
*/
@Before("@within(permission)")
public void checkClassPermission(JoinPoint joinPoint, RequirePermission permission) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
RequirePermission methodPermission = signature.getMethod().getAnnotation(RequirePermission.class);
if (methodPermission != null) {
return;
}
if (!validate(permission)) {
throw new AccessDeniedException("无权限访问该接口");
}
}
private boolean validate(RequirePermission permission) {
if (permission == null) {
return true;
}
if (permission.adminOnly()) {
return permissionService.isSuperAdmin();
}
String code = permission.value();
if (code == null || code.trim().isEmpty()) {
return true;
}
return permissionService.hasPermission(code.trim());
}
}
注意第 4 章中如果想把耗时统计、日志等一起做掉,可以把切入函数从 @Before 换成 @Around,在 proceed() 前后补充逻辑,下面的代码只保留了最核心的权限校验。
4.3 当前登录用户的线程上下文封装
切面里调用 permissionService 时,需要先从 SecurityContextHolder 里取出登录用户。我会提前把用户信息封装成一个 LoginUser 对象放进 Authentication 的 principal 中,而不是每次去 Redis 查字符串。
java复制public class LoginUser implements Serializable {
private Long userId;
private String username;
private String roleCode;
private boolean superAdmin;
private Set<String> permissions;
public boolean hasPermission(String permission) {
if (superAdmin) {
return true;
}
return permissions != null && permissions.contains(permission);
}
}
从上下文取用户的方法也要统一:
java复制public class SecurityUtils {
public static LoginUser getLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) {
throw new NotLoginException("当前用户未登录");
}
return (LoginUser) authentication.getPrincipal();
}
}
这里有一个很容易踩的坑:Spring Security 在未登录状态下,principal 的值并不是 null,而是字符串 "anonymousUser",所以不能用 authentication == null 判断是否登录,而应该判断 principal 类型。很多排错排了半天最后发现是这个原因。
4.4 PermissionService:合权判断与超级管理员放行
PermissionService 的职责只有两个:登录校验和权限判断。代码里需要区分“未登录抛异常”和“无权限抛拒绝”。
java复制@Service
public class PermissionService {
public boolean hasPermission(String permission) {
LoginUser loginUser = SecurityUtils.getLoginUser();
return loginUser.hasPermission(permission);
}
public boolean isSuperAdmin() {
LoginUser loginUser = SecurityUtils.getLoginUser();
return loginUser.isSuperAdmin();
}
}
登录无效时 SecurityUtils.getLoginUser() 会直接抛未登录异常,正常传递到全局异常处理器;如果登录了但没有某个权限,切面会抛出 AccessDeniedException,交给 Spring Security 的 AccessDeniedHandler 或全局异常处理转换成一个统一的 403 响应。Controller 里不会再出现任何 if (xxx.hasPermission()) 的代码。
上面这个 PermissionService 如果结合 Spring Security 原生注解也能用,比如 @PreAuthorize("@permissionService.hasPermission('admin:user:add')"),写法类似若依框架等开源脚手架中的 @ss.hasPermi。这两种方式的核心思路是从权限集合里校验标识,区别只在于把表达式收敛成注解后,少了 SpEL 层,约束更统一。
4.5 类上注解与方法注解共存时的编写规范
当一个 Controller 的类上已经写了 @RequirePermission("admin:user"),其中某个方法又写了自己的权限注解,正确逻辑是方法级注解覆盖类级注解,而不是两个都校验。使用者在类上加注解时,可以理解为“这个类没有特殊标注的方法都默认需要某个权限”,而非“所有方法都必须同时满足类权限和方法权限”。所以切面中看到 methodPermission 不为空就直接 return,避免重复拦截。
这种设计在 Controller 层有很强的可读性:
java复制@RestController
@RequestMapping("/admin/user")
@RequirePermission("admin:user")
public class UserController {
@PostMapping("/add")
@RequirePermission("admin:user:add")
public Result<Void> addUser(@RequestBody UserAddDTO dto) {
// 无需任何权限判断代码
}
@DeleteMapping("/{id}")
@RequirePermission("admin:user:delete")
public Result<Void> deleteUser(@PathVariable Long id) {
// 无需任何权限判断代码
}
}
没有注解的场合,类自动继承 admin:user 作为基础门槛;写了注解的方法,切面只校验方法上的具体权限。代码的可读性和维护性都要好很多。
5. 从后端接口到前端按钮:同一套权限标识怎么打通前后端
5.1 菜单权限和按钮权限的本质
权限标识这套体系建好之后,菜单和后端接口之间就不再是两套割裂的模型了。菜单、按钮在后端其实也是权限表里的数据,它们各自的 perm_code 与后端接口注解上的 perm_code 一一对应。用户登录后,后端把该用户的权限标识集合返回给前端,前端不仅能据此过滤菜单,也能据此决定按钮显示还是隐藏。
菜单数据和按钮数据通过 parent_id 组成树。前端拿到的菜单树如果本身就是按该用户权限过滤过的,那路由也能跟着动态生成。很多人纠结的动态路由难做,根因往往在于权限表没有把菜单节点跟权限标识建立关联。
5.2 前端如何根据权限标识集合控制按钮显隐
登录接口返回的数据里,除了 token,还要带上 permissions 数组:
json复制{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"userInfo": {
"userId": 1001,
"username": "zhangsan",
"roles": ["operator"],
"permissions": ["admin:user:list", "admin:user:add"]
}
}
前端把 permissions 存到状态管理里,按钮组件封装一个共用指令:
javascript复制// v-permission="'admin:user:add'"
const permission = {
mounted(el, binding) {
const required = binding.value;
const permissions = store.state.user.permissions;
if (!permissions.includes(required)) {
el.parentNode?.removeChild(el);
}
}
};
按钮能渲染出来的前提是登录用户拥有对应权限标识。但请记住,前端的隐藏只是体验层的约束,后端接口上的 @RequirePermission("admin:user:add") 才是最终的安全防线。如果一个用户懂技术,绕过按钮直接发请求,后端会兜住。
5.3 常见后台脚手架的做法,以及这套方案的差异
很多热门后台管理脚手架的做法是在接口上用 @PreAuthorize("@ss.hasPermi('system:user:add')") 这种写法,把 Spring Security 原生的方法级注解和一个自定义的校验 Service 组合起来。@ss.hasPermi 的底层就是去当前用户权限集合里查有没有对应标识,和自定义注解切面做的事完全一致。
区别在于,@PreAuthorize 里写的是 SpEL 表达式,团队成员写多了之后容易出现各种写法差异,比如有人写 hasPermission,有人写 hasPermi,命名不统一、可读性和可维护性就下来了。自定义注解把权限字符串直接作为参数,从形式上更接近项目自身的“领域语言”。所以我更推荐在自己的项目里把权限注解做成团队的公共组件。
5.4 顺手的扩展:把注解切面复用到日志、超时监控
自定义注解这套模式一旦跑通,好处不止权限控制。既然切面已经拦截了所有带 @RequirePermission 的接口,完全可以在同一个环绕通知里把权限校验和附加能力串起来。比如接口超时监控,定义一个 @TimeoutWarning 注解:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeoutWarning {
long timeoutMs() default 2000;
}
切面用 Around 包住方法,统计耗时,超过阈值就打印告警日志或上报监控系统。一个方法上同时标注权限注解和超时注解,切面里的执行顺序可以统一编排,代码不会多出一堆监控埋点。权限、日志、幂等控制、超时告警都可以通过这套“注解 + AOP”的机制沉淀下来,长期来看对工程维护的帮助远大于单纯做一个权限注解。
6. 线上踩坑记录:注解失效、缓存不刷新与越权隐患
6.1 切面不生效,按照这个顺序排查
权限注解刚上线时最常遇到的问题就是注解没生效,接口拿一个没有任何权限的账号也能访问。我建议按下面顺序排查。
- 确认切面类被 Spring 管理了。
@Aspect只负责标记切面语义,必须配合@Component或在配置类里通过@Bean注册。 - 确认方法是通过代理对象调用的。同类内部方法直接
this.xxx()调用时,AOP 代理不会生效,因为 Spring AOP 基于代理,只有外部调用走代理。想让内部调用也被拦截,需要注入自身代理,或者把内部逻辑抽到另一个 Bean 中。 - 确认自定义注解和切面的包路径能被扫描到。如果应用主类扫描的包根路径和注解定义不在同一个包树下,注解不会被识别的,最常见的现象是本地能生效、打包到线上就失效。
- 确认目标方法是 public 的。Spring AOP 默认只代理 public 方法,private 方法无论如何都不会被拦截。
切面生效的第一现场是日志。给切面类加一行入口日志,请求接口后如果日志没打出来,基本可以先放弃检查权限逻辑,回到代理和扫描配置上排查。
6.2 权限改了但用户还是没权限 / 还是有权限:缓存刷新策略
用户登录后权限集合如果一直放 Redis,角色权限发生变化时,旧会话里的登录用户不会立即感知。我见过运维改了某个角色的权限,反馈“明明加了权限,用户还是提示无权限”,也有“撤销了权限,用户竟然还能访问”的情况,两者本质都是缓存未刷新。
处理这个问题有几个方案。最简单的是每次请求都动态查一次权限表,但这会让权限判断变成一次额外数据库查询,性能不够好。当前比较推荐的是版本号方案:角色权限发生变化时,把该角色涉及的在线用户权限缓存 key 批量删除,或统一给权限缓存加一个 version 字段;判断权限时如果发现当前缓存版本号落后,就重新加载并刷新缓存。实现起来并不复杂,但能够极大减少“权限变更不生效”的工单。
6.3 异步线程里取不到登录用户,甚至出现串号
Spring Security 的 SecurityContext 默认存在 ThreadLocal 中,主线程里放着当前登录人,但如果业务里使用 @Async 或其他方式开启子线程,子线程默认拿不到主线程的 SecurityContext。在异步逻辑里调 SecurityUtils.getLoginUser() 会抛“未登录”异常。
更隐蔽的是线程池复用时出现的串号问题。子线程跑完后 ThreadLocal 如果没有清理,下一次任务从池中取出同一线程时,还能读到上一个请求的登录用户。权限模块一旦出现串号,意味着某个用户可能拿到另外一个用户的权限,比单纯的无权限危险得多。规避方案有几种:使用 Spring Security 官方的 DelegatingSecurityContextAsyncTaskExecutor 包装线程池;或者在每个异步任务入口显式传入 LoginUser 参数,而非在子线程里取上下文;或者使用 TransmittableThreadLocal 组件做上下文跨线程传递。最好的办法是不要在异步线程里依赖 SecurityContext,直接通过方法参数传递用户信息,简单且无歧义。
6.4 URL 放行顺序与权限标识不一致造成的问题
还有一类越权或误伤和 Spring Security 的 URL 匹配顺序有关。authorizeHttpRequests 里的匹配规则从上到下,先匹配先生效。一个很常见的错误是配置写成了这样:
java复制.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").authenticated()
.requestMatchers("/admin/user/export").permitAll())
由于 /admin/** 在前,/admin/user/export 永远不可能被后面的 permitAll 覆盖,导出接口还是必须登录才能访问。放行规则一定要写在高优先级的位置,把更具体的路径放在更前面。
权限标识本身也要定期检查一致性问题。数据库权限表里的 perm_code、注解上的权限标识、前端按钮的权限值三个地方如果各写各的,很容易出现大小写不同、末尾多空格、用了中文冒号这类问题。建议保存和比较时统一 trim、统一小写,并且提供一个后台的权限标识列表页,方便对照查找重复和不规范的标识。这种地方一旦形成规范,基本不用再处理脏数据。
最后说一点我的个人体会。权限这东西,宁可校验慢一点,也不要放行错一次。接口访问、菜单渲染、按钮显隐,所有入口都围绕同一套 RBAC 数据模型和同一套权限标识来运作,权限管理才会越做越清晰。如果你现在正在改一个权限逻辑散乱的后台系统,我建议你按这套思路把权限标识、注解切面、前端权限集合依次对齐一次,而不是继续在业务代码里继续打补丁式地加判断。改完之后,下一次再遇到“这个角色要能导入能不能导出”的需求,你会发现只是数据库里给角色勾选几个权限标识的事,代码根本不用动。
