Note 15(下). AOP 切面编程实战:理解Springboot中的RABC模型如何使用AOP切面编程优雅的实现

第十五章. AOP 切面编程实战:用 RBAC 模型优雅实现权限控制


15.1. 本章导读

本章核心目标:通过一个完整的 RBAC 权限系统实战,掌握 Spring AOP 的核心用法,实现 “一个注解搞定权限校验” 的优雅方案。阅读完本章后,你将能够独立设计并实现一套声明式的权限控制系统,让业务代码彻底摆脱权限校验逻辑的侵入。

15.1.1. 学习路径与认知里程碑

本章内容较为系统,我们将按照以下路径逐步推进:

阶段一:理论奠基(15.2)

  • 学习目标:理解 RBAC 模型的设计思想
  • 认知里程碑:能够独立设计用户-角色-权限的数据库表结构

阶段二:基础组件搭建(15.3 - 15.4)

  • 学习目标:掌握自定义注解的创建方法,完成数据访问层开发
  • 认知里程碑:能够创建带属性的自定义注解,能够编写多表关联查询

阶段三:AOP 核心实战(15.5)

  • 学习目标:掌握 Spring AOP 切面的完整编写流程
  • 认知里程碑:能够独立实现基于注解的方法拦截与权限校验

阶段四:集成验证(15.6)

  • 学习目标:将所有组件整合并进行全面测试
  • 认知里程碑:能够独立排查权限校验失败的问题

阶段五:生产级优化(15.7 - 15.8)

  • 学习目标:掌握全局异常处理和多权限校验的扩展方案
  • 认知里程碑:能够将权限系统应用于真实生产环境

15.1.2. 环境版本清单

在开始编码之前,请确保你的开发环境与以下版本保持一致,避免因版本差异导致的兼容性问题:

组件版本说明
JDK17+使用 LTS 长期支持版本
Spring Boot3.2.x当前主流稳定版本
MyBatis-Plus3.5.xORM 增强框架
H2 Database2.2.x内嵌数据库,便于演示
Lombok1.18.x简化样板代码

15.2. RBAC 权限模型详解

在动手编码之前,我们必须先理解企业软件权限管理中最主流的模型——RBAC。不理解其设计思想,写出的代码也只是空中楼阁。

15.2.1. 从 “用户-权限” 到 “用户-角色-权限” 的演进

RBAC,即 基于角色的访问控制(Role-Based Access Control)。它的核心思想非常简单,但在用户和权限之间增加了一个至关重要的中间层:“角色”。

传统的权限管理是 “用户-权限” 直接关联。

mermaid-diagram-2025-12-16-140717

这种模式在用户和权限数量少的时候尚可应付。但想象一个拥有 1000 名员工和 200 个操作权限(如 “查看报表”、“编辑商品”、“审核订单” 等)的公司。如果新入职一位销售人员,管理员需要手动为他勾选上他应该拥有的几十个权限。如果一位销售经理离职,管理员又需要将他的权限一一移除。这无疑是低效且易错的。

RBAC 的引入,将授权逻辑优雅地分解为两步:

  1. 为角色分配权限:定义好 “销售”、“经理”、“财务”、“技术支持” 等角色,并为每个角色配置好它们应该拥有的权限集合。例如,“销售” 角色拥有 “查看商品”、“创建订单” 权限;“经理” 角色则额外拥有 “审核订单”、“查看销售报表” 的权限。这一步通常由系统管理员完成,且不经常变动。

  2. 为用户分配角色:当新员工入职时,不再需要关心具体的权限点,只需给他分配一个或多个预设好的角色。例如,给新来的销售分配一个 “销售” 角色,他就自动继承了该角色所关联的所有权限。

mermaid-diagram-2025-12-16-140753

通过引入 “角色” 这个中间层,RBAC 实现了用户与权限的解耦。人事变动时,我们只需要调整用户与角色的关系,而无需触碰背后复杂的权限配置,极大地简化了权限管理和维护的复杂度。

15.2.2. 五张核心表的设计思路与 ER 图

一个标准的 RBAC 系统包含五张核心表,它们共同构成了一个灵活且强大的权限体系:

mermaid-diagram-2025-12-16-140822

表结构说明

  1. sys_user(用户表):存储系统用户的基本信息。
  2. sys_role(角色表):定义系统中的所有角色,如 “管理员”、“普通用户”、“访客” 等。
  3. sys_permission(权限表):定义系统中的所有操作权限,如 “user: delete”、“order: create” 等。
  4. sys_user_role(用户-角色关联表):一个用户可以拥有多个角色,一个角色可以分配给多个用户,这是典型的多对多关系。
  5. sys_role_permission(角色-权限关联表):一个角色可以拥有多个权限,一个权限可以分配给多个角色,同样是多对多关系。

15.2.3. H2 数据库建表脚本

以下是完整的建表脚本,我们使用 H2 内嵌数据库进行演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
-- 1. 用户表
DROP TABLE IF EXISTS sys_user;
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE sys_user IS '用户表';
COMMENT ON COLUMN sys_user.id IS '用户ID';
COMMENT ON COLUMN sys_user.username IS '用户名';
COMMENT ON COLUMN sys_user.password IS '密码(实际项目中需加密)';
COMMENT ON COLUMN sys_user.create_time IS '创建时间';

-- 2. 角色表
DROP TABLE IF EXISTS sys_role;
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(100) NOT NULL,
description VARCHAR(255)
);
COMMENT ON TABLE sys_role IS '角色表';
COMMENT ON COLUMN sys_role.id IS '角色ID';
COMMENT ON COLUMN sys_role.role_code IS '角色编码(如 ADMIN, USER)';
COMMENT ON COLUMN sys_role.role_name IS '角色名称(如 系统管理员)';
COMMENT ON COLUMN sys_role.description IS '角色描述';

-- 3. 权限表
DROP TABLE IF EXISTS sys_permission;
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
permission_code VARCHAR(100) NOT NULL UNIQUE,
permission_name VARCHAR(100) NOT NULL,
resource_type VARCHAR(20)
);
COMMENT ON TABLE sys_permission IS '权限表';
COMMENT ON COLUMN sys_permission.id IS '权限ID';
COMMENT ON COLUMN sys_permission.permission_code IS '权限编码(如 user:delete)';
COMMENT ON COLUMN sys_permission.permission_name IS '权限名称(如 删除用户)';
COMMENT ON COLUMN sys_permission.resource_type IS '资源类型(如 MENU, BUTTON, API)';

-- 4. 用户-角色关联表
DROP TABLE IF EXISTS sys_user_role;
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
CONSTRAINT uk_user_role UNIQUE (user_id, role_id), -- 防止重复分配
FOREIGN KEY (user_id) REFERENCES sys_user(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE
);
COMMENT ON TABLE sys_user_role IS '用户-角色关联表';
COMMENT ON COLUMN sys_user_role.id IS '主键ID';
COMMENT ON COLUMN sys_user_role.user_id IS '用户ID';
COMMENT ON COLUMN sys_user_role.role_id IS '角色ID';

-- 5. 角色-权限关联表
DROP TABLE IF EXISTS sys_role_permission;
CREATE TABLE sys_role_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
CONSTRAINT uk_role_permission UNIQUE (role_id, permission_id), -- 防止重复分配
FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES sys_permission(id) ON DELETE CASCADE
);
COMMENT ON TABLE sys_role_permission IS '角色-权限关联表';
COMMENT ON COLUMN sys_role_permission.id IS '主键ID';
COMMENT ON COLUMN sys_role_permission.role_id IS '角色ID';
COMMENT ON COLUMN sys_role_permission.permission_id IS '权限ID';

15.2.4. 本节小结

本节我们深入理解了 RBAC 权限模型的设计思想,核心要点如下:

概念说明数据库表
用户系统的使用者sys_user
角色权限的集合,是用户与权限之间的桥梁sys_role
权限对资源的操作许可sys_permission
用户-角色关联多对多关系sys_user_role
角色-权限关联多对多关系sys_role_permission

15.3. 自定义注解 @RequiresPermission

理解了 RBAC 模型后,我们面临一个问题:如何在代码中优雅地声明 “访问这个接口需要什么权限”?答案是使用自定义注解。通过在方法上添加一个简单的标记,我们就能声明该方法的权限要求,而具体的校验逻辑则交给 AOP 切面处理。

15.3.1. 元注解 @Target 与 @Retention 的作用

在创建自定义注解之前,我们需要了解两个关键的元注解(用于修饰注解的注解):

@Target:指定注解可以标注在哪些程序元素上。常用的取值包括:

  • ElementType.TYPE:类、接口、枚举
  • ElementType.METHOD:方法
  • ElementType.FIELD:字段
  • ElementType.PARAMETER:方法参数

@Retention:指定注解的保留策略,即注解在什么阶段仍然有效。取值包括:

  • RetentionPolicy.SOURCE:仅在源码中保留,编译时丢弃
  • RetentionPolicy.CLASS:保留到字节码文件,但运行时不可见
  • RetentionPolicy.RUNTIME:运行时仍然可见,可通过反射读取

对于 AOP 权限校验场景,我们需要在运行时读取注解信息,因此必须使用 RetentionPolicy.RUNTIME

15.3.2. 注解属性设计:value() 的命名约定

我们的注解需要承载一个信息:“访问此方法需要什么权限?”。因此,它需要一个属性来接收这个权限字符串。

📄 文件src/main/java/com/example/demo/annotation/RequiresPermission.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* 自定义权限控制注解
* 用于标记在方法上,声明访问该方法所需要的权限。
*/
@Target(ElementType.METHOD) // 1. 元注解:声明此注解只能用于方法上
@Retention(RetentionPolicy.RUNTIME) // 2. 元注解:声明此注解在运行时依然可见,AOP 才能读取到它
public @interface RequiresPermission {

/**
* 定义一个名为 value 的属性,用于接收所需的权限编码。
* 这是注解的默认属性,在使用时可以直接写 @RequiresPermission("user: delete"),
* 而无需写 @RequiresPermission(value = "user: delete")。
* @return 所需的权限编码字符串
*/
String value();
}

这个注解本身没有任何逻辑,它就像一个 “便利贴”,安静地贴在需要保护的方法上,等待着 AOP 这个 “扫描器” 来读取并执行相应的逻辑。

15.3.3. 本节小结

要点说明
@Target(ElementType.METHOD)限制注解只能用于方法
@Retention(RetentionPolicy.RUNTIME)确保运行时可通过反射读取
value() 属性使用默认属性名,简化注解使用语法

15.4. 数据访问层:Entity 与 Mapper

在编写核心切面之前,我们需要先为五张数据库表创建对应的实体类和 Mapper 接口,这样才能在 AOP 中查询用户的权限信息。

15.4.1. 五张表对应的实体类

📄 文件src/main/java/com/example/demo/entity/User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("sys_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private LocalDateTime createTime;
}

📄 文件src/main/java/com/example/demo/entity/Role.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("sys_role")
public class Role {
@TableId(type = IdType.AUTO)
private Long id;
private String roleCode;
private String roleName;
private String description;
}

📄 文件src/main/java/com/example/demo/entity/Permission.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("sys_permission")
public class Permission {
@TableId(type = IdType.AUTO)
private Long id;
private String permissionCode;
private String permissionName;
private String resourceType;
}

📄 文件src/main/java/com/example/demo/entity/UserRole.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("sys_user_role")
public class UserRole {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long roleId;
}

📄 文件src/main/java/com/example/demo/entity/RolePermission.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("sys_role_permission")
public class RolePermission {
@TableId(type = IdType.AUTO)
private Long id;
private Long roleId;
private Long permissionId;
}

15.4.2. 权限查询的核心 SQL:三表连接

📄 文件src/main/java/com/example/demo/mapper/UserMapper.java

1
2
3
4
5
6
7
8
9
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

📄 文件src/main/java/com/example/demo/mapper/RoleMapper.java

1
2
3
4
5
6
7
8
9
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Role;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface RoleMapper extends BaseMapper<Role> {
}

📄 文件src/main/java/com/example/demo/mapper/PermissionMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Permission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;

@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {

/**
* 根据用户 ID 查询该用户拥有的所有权限编码
* 这是一个多表关联查询,需要连接 user_role、role_permission、permission 三张表
*/
@Select("SELECT DISTINCT p.permission_code " +
"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}")
List<String> selectPermissionsByUserId(@Param("userId") Long userId);
}

📄 文件src/main/java/com/example/demo/mapper/UserRoleMapper.java

1
2
3
4
5
6
7
8
9
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.UserRole;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserRoleMapper extends BaseMapper<UserRole> {
}

📄 文件src/main/java/com/example/demo/mapper/RolePermissionMapper.java

1
2
3
4
5
6
7
8
9
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.RolePermission;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface RolePermissionMapper extends BaseMapper<RolePermission> {
}

最关键的是 PermissionMapper 中的 selectPermissionsByUserId 方法。这个方法通过一个三表连接查询,实现了从用户 ID 到权限编码的完整路径:

mermaid-diagram-2025-12-16-143113

SQL 的执行逻辑:

  1. sys_user_role 表中找到该用户拥有的所有角色 ID
  2. sys_role_permission 表中找到这些角色拥有的所有权限 ID
  3. sys_permission 表中获取这些权限 ID 对应的权限编码
  4. 使用 DISTINCT 去重,因为一个用户可能通过不同角色获得相同的权限

15.4.3. 本节小结

组件作用关键点
五个 Entity映射数据库表使用 @TableName 指定表名
UserMapper查询用户信息继承 BaseMapper 获得 CRUD
PermissionMapper查询用户权限三表连接 + DISTINCT 去重

15.5. 权限校验切面 PermissionCheckAspect

注解定义好了,现在我们需要创建真正的 “卫兵”——切面类。这个切面将专门负责拦截所有被 @RequiresPermission 注解标记的方法,并在方法执行前进行权限校验。这是本章最核心的代码,我们将分步构建它。

15.5.1. 切面骨架:@Aspect 与 @Before

首先,我们创建 PermissionCheckAspect 类,并为其添加必要的 Spring AOP 注解。

📄 文件src/main/java/com/example/demo/aspect/PermissionCheckAspect.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demo.aspect;

import com.example.demo.annotation.RequiresPermission;
import com.example.demo.mapper.PermissionMapper;
import com.example.demo.mapper.UserMapper;
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.springframework.stereotype.Component;

@Aspect // 1. 声明这是一个切面类
@Component // 2. 将其注册为 Spring Bean,交由容器管理
@Slf4j // 3. Lombok 注解,提供日志功能
@RequiredArgsConstructor // 4. Lombok 注解,为 final 字段生成构造函数,用于依赖注入
public class PermissionCheckAspect {

// 依赖注入 UserMapper,用于查询用户信息
private final UserMapper userMapper;

// 依赖注入 PermissionMapper,用于查询用户权限
private final PermissionMapper permissionMapper;

/**
* 定义前置通知,切点表达式为 @annotation(requiresPermission)。
* - @annotation(...):这是切点指示符,表示匹配那些被指定注解标记的方法。
* - requiresPermission:这是参数名,Spring AOP 会将匹配到的注解对象本身,注入到这个参数中。
* 这种写法极为强大,让我们可以在通知方法内部直接获取到注解实例及其属性值。
*/
@Before("@annotation(requiresPermission)")
public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) {
log.info("====== 进入 AOP 权限校验 ======");

// 权限校验逻辑将在下面实现
}
}

15.5.2. 切点表达式:@annotation 的绑定技巧

在上面的代码中,@Before("@annotation(requiresPermission)") 这行代码蕴含着一个非常实用的技巧:注解绑定

常规写法是 @Before("@annotation(com.example.demo.annotation.RequiresPermission)"),这种写法只能匹配被该注解标记的方法,但无法在通知方法中直接获取注解实例。

而我们使用的写法 @Before("@annotation(requiresPermission)") 中,requiresPermission 是通知方法的参数名。Spring AOP 会自动将匹配到的注解实例注入到这个参数中,让我们可以直接调用 requiresPermission.value() 获取注解的属性值。

15.5.3. 获取 HTTP 上下文:RequestContextHolder

权限校验的第一步,是明确 “当前是谁在访问”。在 Web 应用中,用户信息通常包含在 HTTP 请求中。我们将模拟一个常见的场景:从请求头中获取用户 ID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在 PermissionCheckAspect.java 的 checkPermission 方法内添加以下逻辑

// 1. 从 AOP 的上下文中获取当前 HTTP 请求对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
// 如果不是在 HTTP 请求上下文中(例如,执行定时任务),则直接跳过校验
log.warn("非 HTTP 请求上下文,跳过权限校验");
return;
}
HttpServletRequest request = attributes.getRequest();

// 2. 模拟从请求头获取当前登录用户的 ID
// 在真实的企业级项目中,用户 ID 通常从 JWT (JSON Web Token) 或分布式 Session 中解析得到
String userIdStr = request.getHeader("X-User-Id");
if (userIdStr == null || userIdStr.isEmpty()) {
// 如果请求头中没有用户信息,说明用户未登录,直接抛出异常
log.error("权限校验失败:请求头缺少 X-User-Id");
throw new RuntimeException("401 Unauthorized: 请求头缺少 X-User-Id,请先登录");
}

Long userId = Long.valueOf(userIdStr);
log.info("当前请求用户 ID: {}", userId);

RequestContextHolder 是 Spring 提供的一个工具类,它可以帮助我们在应用的任何地方获取到与当前线程绑定的 HttpServletRequest 对象。这是在非 Controller 层获取请求信息的标准做法。

request.getHeader("X-User-Id") 表示我们约定,前端在调用需要权限的接口时,必须在 HTTP 请求头(Header)中携带一个名为 X-User-Id 的字段,其值为当前登录用户的 ID。这是一种无状态的、对 RESTful API 友好的身份传递方式。

15.5.4. 权限比对与异常中断

获取到用户 ID 后,我们需要验证用户是否存在,查询该用户拥有的所有权限,然后与目标方法要求的权限进行比对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 接着上面的代码,继续在 checkPermission 方法内完善

// 3. 根据用户 ID 从数据库查询用户信息
User user = userMapper.selectById(userId);
if (user == null) {
log.error("权限校验失败:用户不存在,ID: {}", userId);
throw new RuntimeException("404 Not Found: 用户不存在,ID: " + userId);
}

log.info("查询到用户:{}", user.getUsername());

// 4. 查询该用户拥有的所有权限编码
// 这里调用我们在 PermissionMapper 中定义的多表关联查询方法
List<String> userPermissions = permissionMapper.selectPermissionsByUserId(userId);

log.info("用户 {} 拥有的权限列表: {}", user.getUsername(), userPermissions);

// 5. 从注解实例中获取接口要求的目标权限
String requiredPermission = requiresPermission.value();

log.info("目标接口需要的权限: {}", requiredPermission);

// 6. 核心校验逻辑:检查用户的权限列表中是否包含所需权限
if (userPermissions == null || !userPermissions.contains(requiredPermission)) {
// 如果用户不具备所需权限,抛出运行时异常
// Spring MVC 的全局异常处理器会捕获此异常,并向前端返回一个 HTTP 403 Forbidden 错误
log.error("权限校验失败:用户 {} 缺少权限 {}", user.getUsername(), requiredPermission);
throw new RuntimeException("403 Forbidden: 权限不足,需要权限: " + requiredPermission);
}

log.info("====== 权限校验通过!用户 {} 拥有权限 {} ======", user.getUsername(), requiredPermission);

这段代码中有几个关键点需要理解:

userMapper.selectById(userId) 调用 MyBatis-Plus 提供的 selectById 方法,根据用户 ID 从数据库中高效地查询出 User 对象。

permissionMapper.selectPermissionsByUserId(userId) 是关键的一步。通过我们之前定义的三表连接查询,一次性获取该用户通过其所有角色继承而来的全部权限编码列表。例如,如果用户同时拥有 ADMIN 和 USER 两个角色,这个列表会包含这两个角色的所有权限的并集。

requiresPermission.value() 还记得我们在注解中定义的 String value() 吗?在这里,我们通过 requiresPermission 这个由 AOP 注入的注解实例,直接调用 value() 方法,就轻松获取到了标注在具体方法上的权限编码字符串,例如 “user: delete”。

userPermissions.contains(requiredPermission) 是最核心的权限判断逻辑。我们检查用户的权限列表中是否包含目标权限。这里使用的是精确匹配,实际项目中可能还需要支持通配符匹配(如 user:* 表示所有 user 相关权限)。

throw new RuntimeException(...) 是 AOP 中断方法执行的关键。一旦在前置通知中抛出任何异常,AOP 将会立即停止执行流程,目标业务方法(如 deleteData将完全没有机会被调用。这就像卫兵发现入侵者后直接拉响警报并关闭大门,确保了核心区域的安全。

15.5.5. 完整代码整合

现在让我们将所有代码片段整合在一起,形成完整的 PermissionCheckAspect 类:

📄 文件src/main/java/com/example/demo/aspect/PermissionCheckAspect.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package com.example.demo.aspect;

import com.example.demo.annotation.RequiresPermission;
import com.example.demo.entity.User;
import com.example.demo.mapper.PermissionMapper;
import com.example.demo.mapper.UserMapper;
import jakarta.servlet.http.HttpServletRequest;
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.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.List;

@Aspect // 1. 声明这是一个切面类
@Component // 2. 将其注册为 Spring Bean,交由容器管理
@Slf4j // 3. Lombok 注解,提供日志功能
@RequiredArgsConstructor // 4. Lombok 注解,为 final 字段生成构造函数,用于依赖注入
public class PermissionCheckAspect {

// 依赖注入 UserMapper,用于查询用户信息
private final UserMapper userMapper;

// 依赖注入 PermissionMapper,用于查询用户权限
private final PermissionMapper permissionMapper;

/**
* 定义前置通知,切点表达式为 @annotation(requiresPermission)。
* - @annotation(...):这是切点指示符,表示匹配那些被指定注解标记的方法。
* - requiresPermission:这是参数名,Spring AOP 会将匹配到的注解对象本身,注入到这个参数中。
* 这种写法极为强大,让我们可以在通知方法内部直接获取到注解实例及其属性值。
*/
@Before("@annotation(requiresPermission)")
public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) {
log.info("====== 进入 AOP 权限校验 ======");

// 1. 从 AOP 的上下文中获取当前 HTTP 请求对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
// 如果不是在 HTTP 请求上下文中(例如,执行定时任务),则直接跳过校验
log.warn("非 HTTP 请求上下文,跳过权限校验");
return;
}
HttpServletRequest request = attributes.getRequest();
// 2. 模拟从请求头获取当前登录用户的 ID
// 在真实的企业级项目中,用户 ID 通常从 JWT (JSON Web Token) 或分布式 Session 中解析得到
String userIdStr = request.getHeader("X-User-Id");
if (userIdStr == null || userIdStr.isEmpty()) {
// 如果请求头中没有用户信息,说明用户未登录,直接抛出异常
log.error("权限校验失败:请求头缺少 X-User-Id");
throw new RuntimeException("401 Unauthorized: 请求头缺少 X-User-Id,请先登录");
}
Long userId = Long.valueOf(userIdStr);
log.info("当前请求用户 ID: {}", userId);

// 3. 根据用户 ID 从数据库查询用户信息
User user = userMapper.selectById(userId);
if (user == null) {
log.error("权限校验失败:用户不存在,ID: {}", userId);
throw new RuntimeException("404 Not Found: 用户不存在,ID: " + userId);
}
log.info("查询到用户:{}", user.getUsername());
// 4. 查询该用户拥有的所有权限编码
// 这里调用我们在 PermissionMapper 中定义的多表关联查询方法
List<String> userPermissions = permissionMapper.selectPermissionsByUserId(userId);
log.info("用户 {} 拥有的权限列表: {}", user.getUsername(), userPermissions);

// 5. 从注解实例中获取接口要求的目标权限
String requiredPermission = requiresPermission.value();

log.info("目标接口需要的权限: {}", requiredPermission);

// 6. 核心校验逻辑:检查用户的权限列表中是否包含所需权限
if (userPermissions == null || !userPermissions.contains(requiredPermission)) {
// 如果用户不具备所需权限,抛出运行时异常
// Spring MVC 的全局异常处理器会捕获此异常,并向前端返回一个 HTTP 403 Forbidden 错误
log.error("权限校验失败:用户 {} 缺少权限 {}", user.getUsername(), requiredPermission);
throw new RuntimeException("403 Forbidden: 权限不足,需要权限: " + requiredPermission);
}
log.info("====== 权限校验通过!用户 {} 拥有权限 {} ======", user.getUsername(), requiredPermission);

}
}

至此,我们最核心的 PermissionCheckAspect 切面已经编写完毕。它像一个忠诚的、自动化的卫兵,守护着所有被 @RequiresPermission 标记的 “城门”。

15.5.6. 本节小结

组件作用关键代码
@Aspect声明切面类类级别注解
@Before前置通知,方法执行前触发@Before("@annotation(requiresPermission)")
RequestContextHolder获取 HTTP 请求上下文RequestContextHolder.getRequestAttributes()
注解绑定获取注解实例及属性参数名与切点表达式对应
异常中断阻止目标方法执行throw new RuntimeException(...)

15.6. Controller 层应用与测试验证

万事俱备,只欠东风。现在我们来创建一个 AdminController,并在其方法上使用我们亲手打造的 @RequiresPermission 注解,来验证我们的 AOP 卫兵是否能够正常工作。

15.6.1. 测试接口设计:五种权限场景

📄 文件src/main/java/com/example/demo/controller/AdminController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.demo.controller;

import com.example.demo.annotation.RequiresPermission;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin")
public class AdminController {

@GetMapping("/delete-user")
@RequiresPermission("user:delete") // 声明:需要 "user: delete" 权限才能访问
public String deleteUser() {
// 这段代码只有在 AOP 权限校验通过后,才有机会执行
return "【管理员专属】用户删除成功!";
}

@GetMapping("/delete-data")
@RequiresPermission("data:delete") // 声明:需要 "data: delete" 权限才能访问
public String deleteData() {
return "【管理员专属】敏感数据删除成功!";
}

@GetMapping("/view-user")
@RequiresPermission("user:view") // 声明:需要 "user: view" 权限才能访问
public String viewUser() {
return "【普通用户可访问】用户信息查看成功。";
}

@GetMapping("/view-data")
@RequiresPermission("data:view") // 声明:需要 "data: view" 权限才能访问
public String viewData() {
return "【普通用户可访问】数据查看成功。";
}

@GetMapping("/public-info")
// 此接口没有注解,任何人(只要提供了合法的 X-User-Id)都可以访问
public String getPublicInfo() {
return "这是一个公开接口,无需特定权限。";
}
}

我们创建了 5 个接口,分别对应不同的权限需求:

  1. deleteUser:需要 user:delete 权限(只有 ADMIN 角色拥有)
  2. deleteData:需要 data:delete 权限(只有 ADMIN 角色拥有)
  3. viewUser:需要 user:view 权限(ADMIN 和 USER 角色都拥有)
  4. viewData:需要 data:view 权限(所有角色都拥有)
  5. getPublicInfo:无需任何权限

这样的设计让我们可以测试不同角色访问不同接口时的权限控制效果。

15.6.2. 六个测试用例与预期结果

现在,启动 Spring Boot 应用。我们的数据库中已经通过初始化脚本准备好了测试数据:

  • 用户 1 (admin):拥有 ADMIN 角色,具备所有权限
  • 用户 2 (testuser):拥有 USER 角色,只有 user:viewdata:view 权限
  • 用户 3 (guest):拥有 GUEST 角色,只有 data:view 权限

我们将使用 curl 命令行工具来模拟不同用户的请求。

场景一:管理员访问删除接口(预期:成功)

1
curl -H "X-User-Id: 1" http://localhost:8080/admin/delete-user

预期控制台日志:

1
2
3
4
5
6
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect   : ====== 进入 AOP 权限校验 ======
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect : 当前请求用户 ID: 1
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect : 查询到用户:admin
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect : 用户 admin 拥有的权限列表: [user:view, user:create, user:update, user:delete, data:view, data:delete]
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect : 目标接口需要的权限: user:delete
INFO --- [nio-8080-exec-1] c.e.d.aspect.PermissionCheckAspect : ====== 权限校验通过!用户 admin 拥有权限 user:delete ======

预期 HTTP 响应:

1
【管理员专属】用户删除成功!

测试通过:管理员拥有所有权限,可以成功访问。

场景二:普通用户访问删除接口(预期:失败)

1
curl -H "X-User-Id: 2" http://localhost:8080/admin/delete-user

预期控制台日志:

1
2
3
4
5
6
7
8
9
10
INFO --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect   : ====== 进入 AOP 权限校验 ======
INFO --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect : 当前请求用户 ID: 2
INFO --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect : 查询到用户:testuser
INFO --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect : 用户 testuser 拥有的权限列表: [user:view, data:view]
INFO --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect : 目标接口需要的权限: user:delete
ERROR --- [nio-8080-exec-2] c.e.d.aspect.PermissionCheckAspect : 权限校验失败:用户 testuser 缺少权限 user:delete
ERROR --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]: Servlet.service() for servlet [dispatcherServlet] threw exception
java.lang.RuntimeException: 403 Forbidden: 权限不足,需要权限: user:delete
at com.example.demo.aspect.PermissionCheckAspect.checkPermission(PermissionCheckAspect.java:69)
...

预期 HTTP 响应:

1
2
3
HTTP/1.1 500 Internal Server Error

java.lang.RuntimeException: 403 Forbidden: 权限不足,需要权限: user:delete

测试通过:普通用户没有 user:delete 权限,被成功拦截。目标方法 deleteUser() 完全没有被执行

场景三:普通用户访问查看接口(预期:成功)

1
curl -H "X-User-Id: 2" http://localhost:8080/admin/view-user

预期控制台日志:

1
2
3
4
5
6
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect   : ====== 进入 AOP 权限校验 ======
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect : 当前请求用户 ID: 2
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect : 查询到用户:testuser
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect : 用户 testuser 拥有的权限列表: [user:view, data:view]
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect : 目标接口需要的权限: user:view
INFO --- [nio-8080-exec-3] c.e.d.aspect.PermissionCheckAspect : ====== 权限校验通过!用户 testuser 拥有权限 user:view ======

预期 HTTP 响应:

1
【普通用户可访问】用户信息查看成功。

测试通过:普通用户拥有 user:view 权限,可以成功访问。

场景四:访客访问数据查看接口(预期:成功)

1
curl -H "X-User-Id: 3" http://localhost:8080/admin/view-data

预期控制台日志:

1
2
3
4
5
6
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect   : ====== 进入 AOP 权限校验 ======
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect : 当前请求用户 ID: 3
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect : 查询到用户:guest
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect : 用户 guest 拥有的权限列表: [data:view]
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect : 目标接口需要的权限: data:view
INFO --- [nio-8080-exec-4] c.e.d.aspect.PermissionCheckAspect : ====== 权限校验通过!用户 guest 拥有权限 data:view ======

预期 HTTP 响应:

1
【普通用户可访问】数据查看成功。

测试通过:访客拥有 data:view 权限,可以成功访问。

场景五:访客访问删除接口(预期:失败)

1
curl -H "X-User-Id: 3" http://localhost:8080/admin/delete-data

预期控制台日志:

1
2
3
4
5
6
7
8
9
INFO --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect   : ====== 进入 AOP 权限校验 ======
INFO --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect : 当前请求用户 ID: 3
INFO --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect : 查询到用户:guest
INFO --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect : 用户 guest 拥有的权限列表: [data:view]
INFO --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect : 目标接口需要的权限: data:delete
ERROR --- [nio-8080-exec-5] c.e.d.aspect.PermissionCheckAspect : 权限校验失败:用户 guest 缺少权限 data:delete
ERROR --- [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]: Servlet.service() threw exception
java.lang.RuntimeException: 403 Forbidden: 权限不足,需要权限: data:delete
...

预期 HTTP 响应:

1
2
3
HTTP/1.1 500 Internal Server Error

java.lang.RuntimeException: 403 Forbidden: 权限不足,需要权限: data:delete

测试通过:访客只有只读权限,无法执行删除操作,被成功拦截。

场景六:访问公开接口(预期:成功)

1
curl -H "X-User-Id: 3" http://localhost:8080/admin/public-info

预期控制台日志:

1
(没有 AOP 相关日志,因为该方法没有被 @RequiresPermission 注解标记)

预期 HTTP 响应:

1
这是一个公开接口,无需特定权限。

测试通过:没有注解的方法不会被 AOP 拦截,任何人都可以访问。

通过以上六个场景的测试,我们验证了:

mermaid-diagram

实验全部成功!我们的 AOP 权限卫兵完美地履行了职责,将不合法的访问请求拦截在了业务逻辑的大门之外。我们真正实现了权限逻辑与业务逻辑的彻底解耦。

15.6.3. 本节小结

测试场景用户目标权限用户权限预期结果
场景一adminuser: delete全部权限✅ 成功
场景二testuseruser: deleteuser: view, data: view❌ 拒绝
场景三testuseruser: viewuser: view, data: view✅ 成功
场景四guestdata: viewdata: view✅ 成功
场景五guestdata: deletedata: view❌ 拒绝
场景六guest无注解-✅ 成功

15.7. 全局异常处理:统一错误响应

你可能注意到,当权限校验失败时,返回的是一个 500 错误和完整的堆栈信息。这在生产环境中是不合适的。我们应该返回更友好、更安全的错误响应。

15.7.1. 为什么 500 错误不能直接暴露?

在前面的测试中,当权限校验失败时,服务器返回了 HTTP 500 状态码和完整的 Java 堆栈信息。这存在两个严重问题:

安全风险:堆栈信息暴露了服务器内部的类名、方法名、行号等敏感信息,攻击者可以利用这些信息寻找系统漏洞。

语义错误:HTTP 500 表示 “服务器内部错误”,但权限不足应该返回 403(Forbidden),用户未登录应该返回 401(Unauthorized)。错误的状态码会误导前端的错误处理逻辑。

15.7.2. @RestControllerAdvice 与状态码映射

📄 文件src/main/java/com/example/demo/exception/GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.demo.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

/**
* 全局异常处理器
* 捕获应用中抛出的异常,返回统一格式的错误响应
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

/**
* 处理运行时异常
* 根据异常消息中的状态码前缀,返回相应的 HTTP 状态码
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException e) {
log.error("捕获到运行时异常: {}", e.getMessage());

String message = e.getMessage();
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;

// 根据异常消息判断 HTTP 状态码
if (message != null) {
if (message.startsWith("401")) {
status = HttpStatus.UNAUTHORIZED; // 401 未认证
} else if (message.startsWith("403")) {
status = HttpStatus.FORBIDDEN; // 403 权限不足
} else if (message.startsWith("404")) {
status = HttpStatus.NOT_FOUND; // 404 资源不存在
}
}

// 构建统一的错误响应格式
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("code", status.value());
errorResponse.put("message", message);
errorResponse.put("timestamp", System.currentTimeMillis());

return new ResponseEntity<>(errorResponse, status);
}
}

这段代码中有几个关键点:

@RestControllerAdvice 是 Spring 提供的全局异常处理注解,标注在类上表示这个类会处理所有 Controller 抛出的异常。

@ExceptionHandler(RuntimeException.class) 指定这个方法专门处理 RuntimeException 类型的异常。

智能状态码识别:我们通过检查异常消息的前缀(“401”、“403”、“404”)来决定返回什么 HTTP 状态码,这样前端就能根据标准的 HTTP 状态码做出正确的响应(如跳转到登录页、显示权限不足提示等)。

统一响应格式:返回一个包含 successcodemessagetimestamp 的 JSON 对象,这是 RESTful API 的最佳实践。

现在再次测试场景二(普通用户访问删除接口),响应将变得更加友好:

1
curl -i -H "X-User-Id: 2" http://localhost:8080/admin/delete-user

优化后的响应:

1
2
3
4
5
6
7
8
9
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
"success": false,
"code": 403,
"message": "403 Forbidden: 权限不足,需要权限: user:delete",
"timestamp": 1702732800000
}

这样的响应既专业又安全,不会泄露服务器内部的堆栈信息。

15.7.3. 本节小结

要点说明
@RestControllerAdvice全局异常处理器,捕获所有 Controller 异常
@ExceptionHandler指定处理的异常类型
状态码映射401 未认证、403 权限不足、404 资源不存在
统一响应格式success、code、message、timestamp

15.8. 多权限校验:AND 与 OR 逻辑扩展

在实际业务中,一个接口可能需要多个权限才能访问(如同时需要 “查看” 和 “编辑” 权限),或者满足其中一个权限即可访问。我们可以对注解进行扩展来支持这些场景。

15.8.1. 注解改造:支持 String [] 与 LogicalOperator

📄 文件src/main/java/com/example/demo/annotation/RequiresPermission.java(修改版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* 自定义权限控制注解(增强版)
* 支持多权限校验和逻辑关系配置
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {

/**
* 所需的权限列表
* 当只有一个权限时,可以直接写 @RequiresPermission("user: delete")
* 当有多个权限时,写 @RequiresPermission({"user: view", "user: edit"})
*/
String[] value();

/**
* 权限逻辑关系
* AND: 必须拥有所有权限
* OR: 拥有任意一个权限即可
*/
LogicalOperator logical() default LogicalOperator.AND;

/**
* 权限逻辑操作符枚举
*/
enum LogicalOperator {
AND, // 并且(所有权限都需要)
OR // 或者(任意一个权限即可)
}
}

这次改造的关键变化:

  1. value() 的返回类型从 String 改为 String[],支持传入多个权限
  2. 新增 logical() 属性,用于指定多个权限之间的逻辑关系
  3. 定义了 LogicalOperator 枚举,包含 ANDOR 两种逻辑

15.8.2. 切面改造:containsAll 与 anyMatch

对应地,我们需要修改切面中的权限校验逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 在 PermissionCheckAspect.java 的 checkPermission 方法中修改权限校验部分

// 5. 获取接口要求的权限列表和逻辑关系
String[] requiredPermissions = requiresPermission.value();
RequiresPermission.LogicalOperator logical = requiresPermission.logical();

log.info("目标接口需要的权限: {}, 逻辑关系: {}",
String.join(", ", requiredPermissions), logical);

// 6. 根据逻辑关系进行权限校验
boolean hasPermission = false;

if (logical == RequiresPermission.LogicalOperator.AND) {
// AND 逻辑:必须拥有所有权限
hasPermission = userPermissions != null &&
userPermissions.containsAll(java.util.Arrays.asList(requiredPermissions));
} else {
// OR 逻辑:拥有任意一个权限即可
if (userPermissions != null) {
for (String required : requiredPermissions) {
if (userPermissions.contains(required)) {
hasPermission = true;
break;
}
}
}
}

if (!hasPermission) {
log.error("权限校验失败:用户 {} 缺少权限 {}",
user.getUsername(), String.join(" " + logical + " ", requiredPermissions));
throw new RuntimeException("403 Forbidden: 权限不足,需要权限: " +
String.join(" " + logical + " ", requiredPermissions));
}

log.info("====== 权限校验通过!======");

这段代码的核心逻辑:

AND 逻辑:使用 containsAll() 方法检查用户权限列表是否包含所有要求的权限。只有全部满足才算通过。

OR 逻辑:遍历所有要求的权限,只要用户拥有其中任意一个,就算通过。使用 break 提前退出循环以提高效率。

15.8.3. 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/document")
public class DocumentController {

// 需要同时拥有查看和编辑权限
@GetMapping("/edit")
@RequiresPermission(value = {"document:view", "document:edit"}, logical = RequiresPermission.LogicalOperator.AND)
public String editDocument() {
return "文档编辑成功";
}

// 拥有管理员权限或文档所有者权限即可
@GetMapping("/delete")
@RequiresPermission(value = {"document:delete", "admin:full"}, logical = RequiresPermission.LogicalOperator.OR)
public String deleteDocument() {
return "文档删除成功";
}
}

第一个接口 editDocument 要求用户必须同时拥有 document:viewdocument:edit 两个权限才能访问。这适用于 “编辑前必须能查看” 的业务场景。

第二个接口 deleteDocument 要求用户拥有 document:deleteadmin:full 其中任意一个权限即可。这适用于 “文档所有者或管理员都能删除” 的业务场景。

15.8.4. 本节小结

扩展点原版增强版
权限数量单个 String多个 String[]
逻辑关系AND / OR
校验方式contains()containsAll() / 遍历匹配

15.9. 本章总结与速查手册

15.9.1. RBAC 权限校验完整流程图

让我们用一张完整的流程图来总结整个 RBAC 权限校验系统的运行机制:

mermaid-diagram (1)

流程说明

  1. 请求拦截:客户端发送 HTTP 请求,Spring MVC 的 DispatcherServlet 将请求路由到对应的 Controller 方法。
  2. AOP 介入:由于目标方法被 @RequiresPermission 标注,AOP 切面在方法执行前被触发。
  3. 身份识别:从请求头的 X-User-Id 中提取用户标识。
  4. 数据查询:通过两次数据库查询,先获取用户基本信息,再通过三表连接获取该用户的完整权限列表。
  5. 权限比对:将用户拥有的权限列表与注解要求的权限进行匹配。
  6. 决策执行
    • 如果权限不足,立即抛出异常,目标方法不会被执行
    • 如果权限充足,放行请求,执行真正的业务逻辑

整个过程对业务代码完全透明,实现了权限控制的 “无感植入”。

15.9.2. AOP 核心注解速查表

知识点说明代码示例
切面声明使用 @Aspect@Component@Aspect @Component public class MyAspect {}
切点表达式 - execution按方法签名匹配@Before("execution(* com.example.service.*.*(..))")
切点表达式 - @annotation按注解匹配@Before("@annotation(com.example.anno.Log)")
前置通知方法执行前@Before("pointcut()")
后置通知方法返回后@AfterReturning("pointcut()")
环绕通知完全控制方法执行@Around("pointcut()") Object around(ProceedingJoinPoint pjp)
异常通知方法抛异常时@AfterThrowing("pointcut()")
最终通知无论如何都执行@After("pointcut()")
获取方法信息通过 JoinPointjoinPoint.getSignature().getName()
获取注解实例切点表达式注入@Before("@annotation(myAnno)") void before(MyAnno myAnno)
控制方法执行只能用 @AroundObject result = pjp.proceed();

15.9.3. 场景化代码模板

遇到以下场景时,请直接参考下方模板:

场景一:单权限校验

需求:某接口只有拥有 user:delete 权限的用户才能访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String value();
}

// 2. 创建切面
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionMapper permissionMapper;

@Before("@annotation(permission)")
public void check(RequiresPermission permission) {
Long userId = getCurrentUserId(); // 从请求上下文获取
List<String> userPerms = permissionMapper.selectPermissionsByUserId(userId);
if (!userPerms.contains(permission.value())) {
throw new RuntimeException("403 Forbidden: 权限不足");
}
}
}

// 3. 使用注解
@DeleteMapping("/users/{id}")
@RequiresPermission("user:delete")
public void deleteUser(@PathVariable Long id) {
// 业务逻辑
}

场景二:多权限 AND 校验

需求:编辑文档需要同时拥有 doc:viewdoc:edit 权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 增强注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String[] value();
LogicalOperator logical() default LogicalOperator.AND;
enum LogicalOperator { AND, OR }
}

// 2. 切面校验逻辑
if (logical == LogicalOperator.AND) {
boolean pass = userPerms.containsAll(Arrays.asList(requiredPerms));
if (!pass) throw new RuntimeException("403 Forbidden");
}

// 3. 使用注解
@PutMapping("/docs/{id}")
@RequiresPermission(value = {"doc:view", "doc:edit"}, logical = LogicalOperator.AND)
public void editDoc(@PathVariable Long id) {
// 业务逻辑
}

场景三:多权限 OR 校验

需求:删除文档只需拥有 doc:deleteadmin:full 其中之一即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 切面校验逻辑
if (logical == LogicalOperator.OR) {
boolean pass = false;
for (String required : requiredPerms) {
if (userPerms.contains(required)) {
pass = true;
break;
}
}
if (!pass) throw new RuntimeException("403 Forbidden");
}

// 使用注解
@DeleteMapping("/docs/{id}")
@RequiresPermission(value = {"doc:delete", "admin:full"}, logical = LogicalOperator.OR)
public void deleteDoc(@PathVariable Long id) {
// 业务逻辑
}

本章到此结束。我们从 RBAC 权限模型的理论基础出发,一步步构建了自定义注解、数据访问层、AOP 切面、全局异常处理等完整组件,最终实现了一套声明式的权限控制系统。通过这个实战项目,我们深刻体会到 AOP 在提升代码模块化、可维护性和复用性方面的巨大价值。