权限控制落到方法级,最直接的做法是自定义注解 + Spring AOP。定义 @RequirePermission 注解声明权限码,切面在 @Around 里从请求上下文解析当前用户权限集合,与注解要求的权限比对,通过则 joinPoint.proceed() 放行,否则抛业务异常返回 403。业务代码零侵入,加一个注解就是加一处权限。
一、为什么选注解 + AOP,而不是拦截器
Servlet Filter、HandlerInterceptor、Spring AOP 三层都能做鉴权,拦截点不同。
| 方案 | 作用层级 | 粒度 | 业务侵入 | 适合场景 |
|---|---|---|---|---|
| Filter | Servlet 容器 | URL 级 | 低 | 登录态、全局过滤 |
| Interceptor | Spring MVC | URL / Handler | 中 | 登录校验、统一 header |
| AOP + 注解 | 业务方法 | 方法级 | 极低 | 精细权限、日志、事务 |
文章创作器项目里,文章创建、编辑、删除、调用 AI 生成这些接口的权限各不相同,URL 级配置写起来冗长且容易漏。注解直接标注在方法上,权限码跟方法声明在一起,谁有权限一目了然。
二、定义权限注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String value(); // 权限码,如 article:create
String[] roles() default {}; // 角色要求,空表示不限制
}
@Retention(RUNTIME) 保证注解在运行时能通过反射读到,这是切面取权限配置的前提。@Target(METHOD) 限定只能标在方法上。
三、切面实现
@Around 把权限校验和目标方法执行包在一起,先校验后放行:
@Aspect
@Component
public class PermissionAspect {
private final PermissionService permissionService;
@Around("@annotation(requirePermission)")
public Object check(ProceedingJoinPoint joinPoint,
RequirePermission requirePermission) throws Throwable {
// 从请求上下文取当前用户
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Long userId = JwtUtil.parseUserId(request.getHeader("Authorization"));
// 取权限集合(带缓存)
Set<String> perms = permissionService.getPermissionsByUser(userId);
if (!perms.contains(requirePermission.value())) {
throw new BusinessException(403, "无权限访问:" + requirePermission.value());
}
// 角色校验(可选)
for (String role : requirePermission.roles()) {
if (!permissionService.getRolesByUser(userId).contains(role)) {
throw new BusinessException(403, "角色不符");
}
}
return joinPoint.proceed();
}
}
两个细节值得提。权限集合不能每次请求都查库,用户权限写入 Redis 缓存,修改角色后清缓存,鉴权耗时压到毫秒级。@Around(“@annotation(requirePermission)”) 这种写法把注解实例直接绑进方法参数,省掉反射 getAnnotation 的步骤。
四、在接口上使用
@PostMapping("/article")
@RequirePermission("article:create")
public Result<ArticleVO> create(@RequestBody ArticleCreateDTO dto) {
return articleService.create(dto);
}
@DeleteMapping("/article/{id}")
@RequirePermission("article:delete")
public Result<Void> delete(@PathVariable Long id) {
return articleService.delete(id);
}
权限码存库,通过用户-角色-权限三级关联查到,这里只做”声明 + 校验”两层。
五、落地步骤
- 引入 spring-boot-starter-aop 依赖,Spring Boot 自动装配切面,无需额外配置;
- 建用户、角色、权限三张表,用户角色、角色权限用关联表维护;
- 定义注解,方法上标注权限码;
- 写切面类,解析 Token 取用户,比对权限集合;
- 全局异常处理器捕获 BusinessException,统一返回 403 响应结构;
- 联调验证,权限不足的接口返回 403 且不执行业务代码。
六、注解放在 Controller 还是 Service
两类位置都能用,效果不同。Controller 层鉴权贴近接口语义,权限码跟 REST 资源一一对应,前端报错定位直观,适合文章创建、删除这类对外接口。Service 层鉴权贴近业务,同一方法被多个接口调用时只写一次校验,适合 AI 生成这类内部也会被复用的核心方法。
| 位置 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| Controller 方法 | 权限码与接口资源一一对应,语义清晰 | 内部复用时不生效 | 对外 REST 接口 |
| Service 方法 | 一处校验多处生效,覆盖面全 | 权限码与接口解耦,报错定位略绕 | 被复用的核心逻辑 |
项目里把「对外写操作」放 Controller,把「AI 生成、配额扣减」放 Service,两层配合覆盖完整。
七、三个易踩的坑
自调用失效。同一个类内部方法互相调用,AOP 代理不生效,权限校验被跳过。解决方式是拆到不同类,或通过 AopContext.currentProxy() 拿代理再调。判断是否生效,debug 看注入的 bean 是不是 CGLIB 代理类。
切面顺序。多个切面(鉴权、日志、限流)并存时用 @Order 控制执行顺序,鉴权切面优先执行,先拦截非法请求再记日志,避免日志里混进一堆被拒请求。
切面里抛异常要走业务异常,不要 return null 或吞掉。proceed() 之外的分支异常要被全局异常处理器接住,前端才能拿到统一响应结构可靠处理。
常见问题(FAQ)
Q1:AOP 权限校验在拦截器前面还是后面?
AOP 在 Controller 方法调用时生效,早于业务代码,晚于拦截器。
Q2:注解权限和 Spring Security 怎么配合?
可并存。Spring Security 管登录态,注解切面管细粒度权限码。
Q3:方法内部自调用会跳过鉴权吗?
会。同类内部调用不走代理,需拆类或手动取代理对象。