SpringBoot_MyBatis_RBAC权限系统综合实战
SpringBoot + MyBatis 综合案例:RBAC 权限系统
本章位置:第二阶段 Java 核心框架
对应课程:综合案例:RBAC 权限系统
前置知识:SpringBoot 请求响应、RESTful、MyBatis、MySQL、事务、JavaBean、反射、注解
学习目标:从零搭建一个可运行的 RBAC 权限管理系统,理解用户、角色、权限之间的关系,并完成数据库设计、后端分层、登录、鉴权、用户管理、角色管理、权限分配等核心功能。
一、RBAC 是什么
RBAC:
Role-Based Access Control
中文:
基于角色的访问控制
它的核心思想不是:
用户直接拥有所有权限
而是:
用户
↓
角色
↓
权限
二、最简单例子
假设系统有:
张三
李四
王五
角色:
管理员
普通用户
审核员
权限:
用户查询
用户新增
用户修改
用户删除
订单审核
关系:
张三
↓
管理员
↓
拥有全部权限
李四
↓
普通用户
↓
只有查询权限
王五
↓
审核员
↓
查询 + 审核权限
三、为什么不直接把权限绑定到用户
如果系统有:
1000 个用户
每个用户都单独配置:
查询权限
新增权限
修改权限
删除权限
会非常难维护。
使用角色:
用户
↓
角色
↓
权限
只需要:
把用户加入角色
就能继承角色权限。
四、RBAC 核心关系
最经典:
User
Role
Permission
关系:
User
多对多
Role
Role
多对多
Permission
五、数据库为什么需要中间表
因为:
一个用户可以有多个角色
一个角色可以属于多个用户
属于:
多对多
所以需要:
user_role
六、角色和权限也是多对多
一个角色:
拥有多个权限
一个权限:
也可能被多个角色使用
所以需要:
role_permission
七、本案例最终功能
我们先完成核心功能:
用户登录
查询当前用户
用户分页查询
新增用户
修改用户
删除用户
角色列表
新增角色
修改角色
删除角色
权限列表
给用户分配角色
给角色分配权限
根据登录用户查询权限
接口鉴权
八、本案例技术栈
JDK 17
SpringBoot 3.x
Spring Web
MyBatis
MySQL 8
Maven
Jackson
Validation
RESTful
JWT(可选基础实现)
如果当前学习重点放在 RBAC:
可以先使用简单 Token / Session 思路理解
后面再升级:
Spring Security
九、推荐项目结构
rbac-system
│
├─ pom.xml
│
└─ src
└─ main
├─ java
│ └─ com.example.rbac
│ ├─ RbacApplication.java
│ │
│ ├─ controller
│ │ ├─ AuthController.java
│ │ ├─ UserController.java
│ │ ├─ RoleController.java
│ │ └─ PermissionController.java
│ │
│ ├─ service
│ │ ├─ AuthService.java
│ │ ├─ UserService.java
│ │ ├─ RoleService.java
│ │ └─ PermissionService.java
│ │
│ ├─ mapper
│ │ ├─ UserMapper.java
│ │ ├─ RoleMapper.java
│ │ ├─ PermissionMapper.java
│ │ ├─ UserRoleMapper.java
│ │ └─ RolePermissionMapper.java
│ │
│ ├─ entity
│ │ ├─ User.java
│ │ ├─ Role.java
│ │ └─ Permission.java
│ │
│ ├─ dto
│ │ ├─ LoginDTO.java
│ │ ├─ UserCreateDTO.java
│ │ ├─ UserUpdateDTO.java
│ │ ├─ RoleCreateDTO.java
│ │ ├─ AssignRoleDTO.java
│ │ └─ AssignPermissionDTO.java
│ │
│ ├─ vo
│ │ ├─ LoginVO.java
│ │ ├─ UserVO.java
│ │ └─ RoleVO.java
│ │
│ ├─ common
│ │ ├─ Result.java
│ │ └─ PageResult.java
│ │
│ ├─ exception
│ │ ├─ BusinessException.java
│ │ └─ GlobalExceptionHandler.java
│ │
│ ├─ security
│ │ ├─ LoginUser.java
│ │ ├─ TokenUtil.java
│ │ ├─ AuthInterceptor.java
│ │ └─ RequirePermission.java
│ │
│ └─ config
│ └─ WebMvcConfig.java
│
└─ resources
├─ application.yml
├─ mapper
│ ├─ UserMapper.xml
│ ├─ RoleMapper.xml
│ └─ PermissionMapper.xml
└─ schema.sql
十、IDEA 从零创建项目
可以:
File
→ New
→ Project
选择:
Spring Initializr
JDK:
17
依赖选择:
Spring Web
Validation
MySQL Driver
MyBatis 如果 Initializr 没直接提供:
后面手动加 Maven 依赖
十一、IDEA 创建包快捷方式
在:
src/main/java
右键:
New
→ Package
或者:
Alt + Insert
选择:
Package
依次创建:
controller
service
mapper
entity
dto
vo
common
exception
security
config
十二、Maven 核心依赖
示例:
<dependencies>
<dependency>
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-starter-web
</artifactId>
</dependency>
<dependency>
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-starter-validation
</artifactId>
</dependency>
<dependency>
<groupId>
org.mybatis.spring.boot
</groupId>
<artifactId>
mybatis-spring-boot-starter
</artifactId>
<version>
3.0.4
</version>
</dependency>
<dependency>
<groupId>
com.mysql
</groupId>
<artifactId>
mysql-connector-j
</artifactId>
<scope>
runtime
</scope>
</dependency>
</dependencies>
十三、数据库设计
我们设计五张核心表:
sys_user
sys_role
sys_permission
sys_user_role
sys_role_permission
十四、用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
nickname VARCHAR(50),
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
十五、角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(50) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
十六、权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
permission_code VARCHAR(100) NOT NULL UNIQUE,
permission_name VARCHAR(100) NOT NULL,
permission_type VARCHAR(20) NOT NULL,
parent_id BIGINT DEFAULT 0,
path VARCHAR(200),
method VARCHAR(20),
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
十七、permission_type
可以设计:
MENU
BUTTON
API
例如:
MENU
用户管理菜单
BUTTON
用户删除按钮
API
DELETE /api/users/{id}
十八、用户角色中间表
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
)
);
二十、为什么中间表使用联合主键
例如:
user_id=1
role_id=2
不应该重复插入两次。
所以:
(user_id, role_id)
作为联合主键。
二十一、初始化角色
INSERT INTO sys_role
(
role_code,
role_name
)
VALUES
(
'ADMIN',
'管理员'
),
(
'USER',
'普通用户'
);
二十二、初始化权限
INSERT INTO sys_permission
(
permission_code,
permission_name,
permission_type,
path,
method
)
VALUES
(
'user:list',
'用户查询',
'API',
'/api/users',
'GET'
),
(
'user:add',
'用户新增',
'API',
'/api/users',
'POST'
),
(
'user:update',
'用户修改',
'API',
'/api/users/{id}',
'PUT'
),
(
'user:delete',
'用户删除',
'API',
'/api/users/{id}',
'DELETE'
);
二十三、application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/rbac_db
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations:
- classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
二十四、User 实体类
public class User {
private Long id;
private String username;
private String password;
private String nickname;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
IDEA:
Alt + Insert
生成:
Getter / Setter
二十五、Role 实体类
public class Role {
private Long id;
private String roleCode;
private String roleName;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
二十六、Permission 实体类
public class Permission {
private Long id;
private String permissionCode;
private String permissionName;
private String permissionType;
private Long parentId;
private String path;
private String method;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
二十七、DTO 为什么要单独设计
不能所有接口都直接接收:
User Entity
因为 User 里:
id
status
createTime
updateTime
password
不是所有接口都应该让前端修改。
二十八、LoginDTO
public class LoginDTO {
@NotBlank(
message = "用户名不能为空"
)
private String username;
@NotBlank(
message = "密码不能为空"
)
private String password;
}
二十九、UserCreateDTO
public class UserCreateDTO {
@NotBlank(
message = "用户名不能为空"
)
private String username;
@NotBlank(
message = "密码不能为空"
)
private String password;
private String nickname;
}
三十、UserUpdateDTO
public class UserUpdateDTO {
private String nickname;
private Integer status;
}
三十一、AssignRoleDTO
public class AssignRoleDTO {
@NotEmpty(
message = "角色不能为空"
)
private List<Long> roleIds;
}
三十二、AssignPermissionDTO
public class AssignPermissionDTO {
@NotEmpty(
message = "权限不能为空"
)
private List<Long> permissionIds;
}
三十三、统一响应 Result
public class Result<T> {
private String code;
private String message;
private T data;
public static <T>
Result<T> success(
T data
) {
Result<T> result =
new Result<>();
result.code =
"SUCCESS";
result.message =
"success";
result.data =
data;
return result;
}
public static <T>
Result<T> fail(
String code,
String message
) {
Result<T> result =
new Result<>();
result.code =
code;
result.message =
message;
return result;
}
}
三十四、业务异常
public class BusinessException
extends RuntimeException {
private final String code;
public BusinessException(
String code,
String message
) {
super(
message
);
this.code =
code;
}
public String getCode() {
return code;
}
}
三十五、为什么需要业务异常
例如:
用户不存在
用户名已存在
角色不存在
密码错误
权限不足
这些不是:
系统崩溃
而是:
业务错误
三十六、全局异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(
BusinessException.class
)
public ResponseEntity<Result<Void>>
handleBusiness(
BusinessException e
) {
return ResponseEntity
.badRequest()
.body(
Result.fail(
e.getCode(),
e.getMessage()
)
);
}
}
三十七、UserMapper
@Mapper
public interface UserMapper {
User findById(
Long id
);
User findByUsername(
String username
);
List<User> findPage(
@Param("keyword")
String keyword,
@Param("offset")
int offset,
@Param("pageSize")
int pageSize
);
long count(
@Param("keyword")
String keyword
);
int insert(
User user
);
int update(
User user
);
int deleteById(
Long id
);
}
三十八、UserMapper.xml
namespace:
<mapper
namespace="com.example.rbac.mapper.UserMapper">
三十九、查询用户
<select
id="findById"
resultType="User">
SELECT
id,
username,
password,
nickname,
status,
create_time,
update_time
FROM sys_user
WHERE id = #{id}
</select>
四十、根据用户名查询
<select
id="findByUsername"
resultType="User">
SELECT
id,
username,
password,
nickname,
status,
create_time,
update_time
FROM sys_user
WHERE username = #{username}
</select>
四十一、分页查询
<select
id="findPage"
resultType="User">
SELECT
id,
username,
password,
nickname,
status,
create_time,
update_time
FROM sys_user
<where>
<if test="keyword != null and keyword != ''">
username LIKE CONCAT(
'%',
#{keyword},
'%'
)
OR
nickname LIKE CONCAT(
'%',
#{keyword},
'%'
)
</if>
</where>
ORDER BY id DESC
LIMIT
#{offset},
#{pageSize}
</select>
四十二、为什么查询时不应该最终返回 password
Mapper 查询 password:
登录校验需要
但是 Controller 返回前端时:
必须转 UserVO
不要直接:
return User Entity
四十三、UserVO
public class UserVO {
private Long id;
private String username;
private String nickname;
private Integer status;
private List<String> roleCodes;
}
四十四、UserService
@Service
public class UserService {
private final UserMapper
userMapper;
public UserService(
UserMapper userMapper
) {
this.userMapper =
userMapper;
}
}
四十五、为什么推荐构造器注入
比字段:
@Autowired
private UserMapper userMapper;
更推荐:
public UserService(
UserMapper userMapper
)
优点:
依赖清晰
更容易测试
可以 final
避免隐藏依赖
四十六、新增用户 Service
@Transactional
public Long create(
UserCreateDTO dto
) {
User exists =
userMapper
.findByUsername(
dto.getUsername()
);
if (
exists != null
) {
throw new BusinessException(
"USER_EXISTS",
"用户名已存在"
);
}
User user =
new User();
user.setUsername(
dto.getUsername()
);
user.setPassword(
dto.getPassword()
);
user.setNickname(
dto.getNickname()
);
user.setStatus(
1
);
userMapper.insert(
user
);
return user.getId();
}
四十七、密码不能明文保存
上面的:
user.setPassword(
dto.getPassword()
);
只是为了先理解流程。
真实项目:
绝对不要保存明文密码
应该:
哈希
例如:
BCrypt
四十八、密码哈希
正确思想:
用户输入密码
↓
BCrypt hash
↓
数据库保存 hash
登录:
用户输入密码
↓
BCrypt.matches()
↓
比较
不是:
解密数据库密码
四十九、RoleMapper
@Mapper
public interface RoleMapper {
Role findById(
Long id
);
List<Role> findAll();
List<Role> findByUserId(
Long userId
);
int insert(
Role role
);
int update(
Role role
);
int deleteById(
Long id
);
}
五十、PermissionMapper
@Mapper
public interface PermissionMapper {
List<Permission> findAll();
List<Permission> findByRoleId(
Long roleId
);
List<Permission> findByUserId(
Long userId
);
}
五十一、查询用户权限 SQL
核心 SQL:
SELECT DISTINCT
p.id,
p.permission_code,
p.permission_name,
p.permission_type,
p.path,
p.method
FROM sys_user_role ur
INNER JOIN sys_role_permission rp
ON ur.role_id = rp.role_id
INNER JOIN sys_permission p
ON rp.permission_id = p.id
WHERE ur.user_id = ?;
五十二、这条 SQL 非常重要
它体现:
User
↓
UserRole
↓
Role
↓
RolePermission
↓
Permission
也就是:
用户通过角色获得权限
五十三、查询用户角色
SELECT
r.id,
r.role_code,
r.role_name
FROM sys_user_role ur
INNER JOIN sys_role r
ON ur.role_id = r.id
WHERE ur.user_id = ?;
五十四、UserRoleMapper
@Mapper
public interface UserRoleMapper {
int deleteByUserId(
Long userId
);
int batchInsert(
@Param("userId")
Long userId,
@Param("roleIds")
List<Long> roleIds
);
}
五十五、批量插入用户角色
<insert
id="batchInsert">
INSERT INTO sys_user_role
(
user_id,
role_id
)
VALUES
<foreach
collection="roleIds"
item="roleId"
separator=","
>
(
#{userId},
#{roleId}
)
</foreach>
</insert>
五十六、给用户分配角色
Service:
@Transactional
public void assignRoles(
Long userId,
List<Long> roleIds
) {
User user =
userMapper.findById(
userId
);
if (
user == null
) {
throw new BusinessException(
"USER_NOT_FOUND",
"用户不存在"
);
}
userRoleMapper.deleteByUserId(
userId
);
if (
roleIds != null
&&
!roleIds.isEmpty()
) {
userRoleMapper.batchInsert(
userId,
roleIds
);
}
}
五十七、为什么这里必须事务
流程:
删除旧角色
↓
插入新角色
如果:
删除成功
插入失败
但没有事务:
用户角色会全部丢失
所以必须:
@Transactional
五十八、事务成功结果
旧角色删除
+
新角色插入
一起:
commit
五十九、事务失败结果
只要中间发生:
RuntimeException
默认:
rollback
六十、角色分配权限也是一样
delete old
↓
insert new
必须:
事务
六十一、RolePermissionMapper
@Mapper
public interface RolePermissionMapper {
int deleteByRoleId(
Long roleId
);
int batchInsert(
@Param("roleId")
Long roleId,
@Param("permissionIds")
List<Long> permissionIds
);
}
六十二、角色分配权限 Service
@Transactional
public void assignPermissions(
Long roleId,
List<Long> permissionIds
) {
Role role =
roleMapper.findById(
roleId
);
if (
role == null
) {
throw new BusinessException(
"ROLE_NOT_FOUND",
"角色不存在"
);
}
rolePermissionMapper
.deleteByRoleId(
roleId
);
if (
permissionIds != null
&&
!permissionIds.isEmpty()
) {
rolePermissionMapper
.batchInsert(
roleId,
permissionIds
);
}
}
六十三、AuthService
登录流程:
接收 username/password
↓
根据 username 查用户
↓
用户是否存在
↓
状态是否正常
↓
密码是否匹配
↓
查询角色
↓
查询权限
↓
生成登录凭证
↓
返回
六十四、LoginVO
public class LoginVO {
private String token;
private Long userId;
private String username;
private List<String> roles;
private List<String> permissions;
}
六十五、最简登录实现
学习阶段可以先:
public LoginVO login(
LoginDTO dto
) {
User user =
userMapper.findByUsername(
dto.getUsername()
);
if (
user == null
) {
throw new BusinessException(
"LOGIN_FAILED",
"用户名或密码错误"
);
}
if (
user.getStatus() == 0
) {
throw new BusinessException(
"USER_DISABLED",
"用户已禁用"
);
}
if (
!passwordMatches(
dto.getPassword(),
user.getPassword()
)
) {
throw new BusinessException(
"LOGIN_FAILED",
"用户名或密码错误"
);
}
List<Role> roles =
roleMapper.findByUserId(
user.getId()
);
List<Permission> permissions =
permissionMapper.findByUserId(
user.getId()
);
String token =
tokenUtil.createToken(
user.getId()
);
// 组装 LoginVO
}
六十六、为什么用户名不存在和密码错误返回同一句
不推荐:
用户名不存在
或者:
密码错误
区别太明确可能帮助攻击者:
枚举有效用户名
更安全:
用户名或密码错误
六十七、Token 是什么
Token:
客户端登录后获得的凭证
以后每个请求:
携带 Token
服务器:
识别用户身份
六十八、常见 Header
Authorization: Bearer <token>
六十九、JWT 简单理解
JWT:
JSON Web Token
常包含:
Header
Payload
Signature
Payload 里可以放:
userId
username
过期时间
七十、JWT 不是加密
JWT 常见:
签名
Payload 通常可以被解码。
所以:
不要放密码
不要放敏感隐私
七十一、JWT 核心作用
验证 Token 是否被篡改
携带有限身份信息
设置过期时间
七十二、登录认证和权限授权区别
认证:
Authentication
回答:
你是谁?
授权:
Authorization
回答:
你能做什么?
七十三、登录属于认证
例如:
username/password
验证成功:
你是 userId=1
七十四、权限校验属于授权
例如:
userId=1
是否拥有:
user:delete
七十五、权限码设计
推荐:
模块:动作
例如:
user:list
user:add
user:update
user:delete
role:list
role:assign
permission:list
七十六、为什么使用权限码
比:
直接判断 role=ADMIN
更灵活。
例如:
运营人员
也可能获得:
user:list
但不是:
ADMIN
七十七、不要把权限判断写死成角色
不推荐:
if (
role.equals(
"ADMIN"
)
) {
// 允许删除
}
推荐:
检查 permissionCode
七十八、自定义权限注解
可以设计:
@Target(
ElementType.METHOD
)
@Retention(
RetentionPolicy.RUNTIME
)
public @interface RequirePermission {
String value();
}
七十九、Controller 使用权限注解
@DeleteMapping(
"/{id}"
)
@RequirePermission(
"user:delete"
)
public Result<Void> delete(
@PathVariable
Long id
) {
userService.deleteById(
id
);
return Result.success(
null
);
}
八十、为什么注解适合权限控制
它把:
接口需要什么权限
直接写在接口方法旁边。
非常直观:
声明式权限
八十一、权限拦截器思路
请求:
DELETE /api/users/1
执行顺序:
Token 解析
↓
获取当前用户
↓
找到 Controller 方法
↓
读取 @RequirePermission
↓
检查用户权限
↓
允许 / 拒绝
八十二、AuthInterceptor
骨架:
public class AuthInterceptor
implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
return true;
}
}
八十三、handler
并不是每次都是 Controller 方法。
所以先判断:
if (
!(handler
instanceof HandlerMethod handlerMethod)
) {
return true;
}
八十四、读取权限注解
RequirePermission permission =
handlerMethod
.getMethodAnnotation(
RequirePermission.class
);
如果:
permission == null
说明:
当前方法没有权限要求
八十五、读取 Token
String authorization =
request.getHeader(
"Authorization"
);
常见格式:
Bearer xxxxxx
八十六、解析用户
Token
↓
userId
然后:
查询/读取用户权限
八十七、权限校验
例如:
if (
!permissionCodes.contains(
permission.value()
)
) {
throw new BusinessException(
"FORBIDDEN",
"无权访问"
);
}
八十八、401 和 403
没有登录:
401
登录了但没权限:
403
不要混淆。
八十九、WebMvcConfig 注册拦截器
@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/auth/login"
);
}
}
九十、为什么登录接口要排除
如果:
登录接口本身也要求登录
那用户永远登录不了。
所以:
/api/auth/login
必须排除。
九十一、当前登录用户怎么传下去
可以使用:
request attribute
ThreadLocal
Spring Security Context
学习阶段可以先:
request.setAttribute
九十二、LoginUser
例如:
public class LoginUser {
private Long userId;
private String username;
private Set<String> permissions;
}
九十三、request 保存当前用户
拦截器:
request.setAttribute(
"loginUser",
loginUser
);
Controller:
LoginUser loginUser =
(LoginUser)
request.getAttribute(
"loginUser"
);
九十四、为什么不要把当前用户放 Servlet 成员变量
因为:
Spring Controller 默认也是单例 Bean
多个请求共享对象。
所以:
当前请求数据
不能放普通成员变量。
九十五、Spring Bean 默认单例
例如:
@RestController
@Service
默认创建:
单例对象
所以必须避免:
成员变量保存请求状态
九十六、分页查询用户
Controller:
@GetMapping
@RequirePermission(
"user:list"
)
public Result<PageResult<UserVO>>
list(
@RequestParam(
defaultValue = "1"
)
int pageNum,
@RequestParam(
defaultValue = "10"
)
int pageSize,
@RequestParam(
required = false
)
String keyword
) {
return Result.success(
userService.findPage(
keyword,
pageNum,
pageSize
)
);
}
九十七、新增用户接口
@PostMapping
@RequirePermission(
"user:add"
)
public ResponseEntity<Result<Long>>
create(
@Valid
@RequestBody
UserCreateDTO dto
) {
Long id =
userService.create(
dto
);
return ResponseEntity
.status(
HttpStatus.CREATED
)
.body(
Result.success(
id
)
);
}
九十八、修改用户接口
@PutMapping(
"/{id}"
)
@RequirePermission(
"user:update"
)
public Result<Void> update(
@PathVariable
Long id,
@Valid
@RequestBody
UserUpdateDTO dto
) {
userService.update(
id,
dto
);
return Result.success(
null
);
}
九十九、删除用户接口
@DeleteMapping(
"/{id}"
)
@RequirePermission(
"user:delete"
)
public ResponseEntity<Void>
delete(
@PathVariable
Long id
) {
userService.deleteById(
id
);
return ResponseEntity
.noContent()
.build();
}
一百、给用户分配角色接口
@PutMapping(
"/{id}/roles"
)
@RequirePermission(
"user:assign-role"
)
public Result<Void>
assignRoles(
@PathVariable
Long id,
@Valid
@RequestBody
AssignRoleDTO dto
) {
userService.assignRoles(
id,
dto.getRoleIds()
);
return Result.success(
null
);
}
一百零一、为什么这个 URL 合理
PUT /users/{id}/roles
可以理解:
把某用户的角色集合
更新成请求中给出的集合
这和 PUT 的:
替换资源状态
语义比较契合。
一百零二、角色接口
可以设计:
GET /api/roles
GET /api/roles/{id}
POST /api/roles
PUT /api/roles/{id}
DELETE /api/roles/{id}
PUT /api/roles/{id}/permissions
一百零三、权限接口
GET /api/permissions
初学案例中:
权限通常先由管理员维护
也可以进一步支持:
新增/修改/删除权限
一百零四、删除角色前要检查什么
不能直接:
DELETE role
要考虑:
是否有用户仍绑定该角色
是否有权限关系
是否是系统内置角色
一百零五、删除用户前要处理中间表
如果没有外键级联:
先删 user_role
↓
再删 user
并且:
同一个事务
一百零六、删除角色事务
可能:
删除 role_permission
删除 user_role
删除 role
这三个:
必须整体事务
一百零七、事务案例
@Transactional
public void deleteRole(
Long roleId
) {
rolePermissionMapper
.deleteByRoleId(
roleId
);
userRoleMapper
.deleteByRoleId(
roleId
);
roleMapper.deleteById(
roleId
);
}
一百零八、为什么 RBAC 是事务练习好案例
因为权限系统大量存在:
主表
中间表
多个关系更新
非常适合练习:
事务原子性
一百零九、角色权限查询
角色详情通常需要:
角色基础信息
权限 ID 列表
权限对象列表
可以:
多次查询
或者 JOIN
一百一十、是否一定要一条 SQL 查完
不一定。
简单清晰:
RoleMapper.findById
PermissionMapper.findByRoleId
两条查询完全可以接受。
不要为了:
“一条 SQL”
写极度复杂 JOIN。
一百一十一、RBAC 查询中容易产生 N+1
比如:
查询 100 个用户
然后每个用户:
再查询角色
变成:
1 + 100
SQL。
一百一十二、避免 N+1 的方法
可以:
批量查询用户角色
JOIN
一次查询后 Java 分组
一百一十三、批量查询用户角色
例如:
SELECT
ur.user_id,
r.id,
r.role_code,
r.role_name
FROM sys_user_role ur
INNER JOIN sys_role r
ON ur.role_id = r.id
WHERE ur.user_id IN (...);
然后 Java:
groupingBy userId
一百一十四、用户分页千万不要先联表全权限
如果:
User × Role × Permission
直接 JOIN 后分页:
一行用户可能被展开很多行
会导致:
分页数量错误
一百一十五、正确分页思路
推荐:
先分页查用户
↓
拿当前页 userIds
↓
批量查这些用户角色
↓
Java 组装
一百一十六、为什么这是企业项目常见套路
因为:
主表分页
最稳定。
关联集合:
第二次批量补齐
避免:
JOIN 导致主表重复
一百一十七、用户状态
可以设计:
1
正常
0
禁用
登录时:
status=0
直接拒绝
一百一十八、角色状态
角色:
status=0
可以理解:
角色禁用
那么计算权限时:
不应该再算这个角色权限
一百一十九、权限是否需要 status
也可以加:
status
控制权限是否有效。
学习案例中:
可以先不加
一百二十、超级管理员怎么办
一种做法:
ADMIN 角色
绑定全部权限
这是最容易理解的。
一百二十一、另一种超级管理员做法
代码里:
userId=1
永远放行
不推荐作为通用设计。
因为:
逻辑写死
一百二十二、权限数据应该放 Token 吗
可以放:
少量 role 信息
但如果权限很多:
Token 会很大
而且权限修改后:
旧 Token 中权限可能过期
一百二十三、常见方案
Token 只保存:
userId
请求时:
根据 userId
查询缓存中的权限
更容易实时更新。
一百二十四、为什么 Redis 常用于权限缓存
每个请求都查:
user_role
role_permission
permission
成本较高。
可以缓存:
user:permission:{userId}
后面 Redis 阶段再系统学习。
一百二十五、权限变更后怎么办
例如:
管理员给用户增加角色
如果权限有缓存:
必须删除/刷新缓存
否则:
新权限不生效
一百二十六、缓存最难的是失效
不是:
怎么 set
而是:
什么时候删
RBAC 是理解缓存一致性的好例子。
一百二十七、密码重置
不应该:
管理员直接看到用户旧密码
因为数据库应该只有:
密码 hash
可以提供:
重置密码
生成:
新临时密码
或者:
让用户自行重设
一百二十八、不要把 password 返回前端
UserVO:
不包含 password
这条是:
必须遵守
一百二十九、登录返回数据
示例:
{
"code": "SUCCESS",
"message": "success",
"data": {
"token": "xxxx",
"userId": 1,
"username": "admin",
"roles": [
"ADMIN"
],
"permissions": [
"user:list",
"user:add",
"user:update",
"user:delete"
]
}
}
一百三十、前端菜单怎么做
登录后前端拿到:
permissions
可以:
决定哪些按钮显示
例如:
有 user:add
显示新增按钮
一百三十一、前端按钮控制只是体验
真正安全仍然依赖:
后端 @RequirePermission
一百三十二、菜单权限和接口权限可以分开
例如:
menu:user
控制:
用户管理菜单
接口:
user:list
user:add
user:update
user:delete
控制:
具体操作
一百三十三、权限树
如果权限包含菜单层级:
系统管理
├─ 用户管理
│ ├─ 查询
│ ├─ 新增
│ ├─ 修改
│ └─ 删除
└─ 角色管理
可以使用:
parent_id
建立树形结构。
一百三十四、树结构基本字段
id
parent_id
permission_name
permission_code
type
一百三十五、根节点
一般:
parent_id = 0
一百三十六、构建权限树
思路:
查询所有权限
↓
Map<parentId, children>
↓
找到 parentId=0
↓
递归组装 children
一百三十七、PermissionVO
public class PermissionVO {
private Long id;
private String permissionCode;
private String permissionName;
private String permissionType;
private Long parentId;
private List<PermissionVO> children;
}
一百三十八、构建树不能每个节点查数据库
错误:
查一个节点
↓
再查 children
↓
每个 child 再查
容易:
N+1
推荐:
一次查询全部
Java 内存组装
一百三十九、分页对象
public class PageResult<T> {
private List<T> records;
private long total;
private int pageNum;
private int pageSize;
}
一百四十、用户分页接口
GET /api/users?pageNum=1&pageSize=10&keyword=admin
一百四十一、角色列表接口
GET /api/roles
角色数量通常较少:
可以不分页
如果系统规模大:
也可以分页
一百四十二、权限树接口
GET /api/permissions/tree
适合给:
角色分配权限页面
使用。
一百四十三、用户分配角色页面需要什么接口
至少:
GET /api/roles
GET /api/users/{id}/roles
PUT /api/users/{id}/roles
一百四十四、角色分配权限页面需要什么接口
GET /api/permissions/tree
GET /api/roles/{id}/permissions
PUT /api/roles/{id}/permissions
一百四十五、RBAC API 设计清单
认证:
POST /api/auth/login
GET /api/auth/me
POST /api/auth/logout
用户:
GET /api/users
GET /api/users/{id}
POST /api/users
PUT /api/users/{id}
DELETE /api/users/{id}
GET /api/users/{id}/roles
PUT /api/users/{id}/roles
角色:
GET /api/roles
GET /api/roles/{id}
POST /api/roles
PUT /api/roles/{id}
DELETE /api/roles/{id}
GET /api/roles/{id}/permissions
PUT /api/roles/{id}/permissions
权限:
GET /api/permissions
GET /api/permissions/tree
一百四十六、登录接口
@RestController
@RequestMapping(
"/api/auth"
)
public class AuthController {
private final AuthService
authService;
public AuthController(
AuthService authService
) {
this.authService =
authService;
}
@PostMapping(
"/login"
)
public Result<LoginVO> login(
@Valid
@RequestBody
LoginDTO dto
) {
return Result.success(
authService.login(
dto
)
);
}
}
一百四十七、获取当前用户
GET /api/auth/me
返回:
当前登录用户
角色
权限
一百四十八、为什么需要 /me
前端刷新页面后:
仍然需要恢复当前用户信息
所以:
/me
很常见。
一百四十九、退出登录
如果 Token 完全无状态:
客户端删除 Token
即可。
如果后端维护:
黑名单 / Redis Token
还需要:
POST /api/auth/logout
让服务端失效。
一百五十、RBAC 开发正确顺序
推荐严格:
1. 建数据库
2. 建 SpringBoot 项目
3. 配 DataSource
4. 配 MyBatis
5. 写 User/Role/Permission Entity
6. UserMapper 跑通
7. RoleMapper 跑通
8. PermissionMapper 跑通
9. 做用户 CRUD
10. 做角色 CRUD
11. 做权限查询
12. 用户分配角色
13. 角色分配权限
14. 查询用户最终权限
15. 登录
16. Token
17. Interceptor
18. RequirePermission
19. 全局异常
20. 完整联调
一百五十一、为什么不要先做 JWT
很多初学者一上来:
JWT
拦截器
权限注解
数据库关系都没跑通。
结果:
一层套一层
越来越乱
正确:
先数据
再业务
再认证
再授权
一百五十二、第一阶段验证
先确保:
UserMapper.findById
能查出用户。
一百五十三、第二阶段验证
确保:
userId
↓
RoleMapper.findByUserId
能查出角色。
一百五十四、第三阶段验证
确保:
userId
↓
PermissionMapper.findByUserId
能查出最终权限。
一百五十五、第四阶段再做登录
这样登录成功后:
角色和权限
都已经有稳定的数据来源。
一百五十六、第五阶段再做鉴权
拦截器只负责:
判断当前用户是否具有权限
不要把:
角色查询
权限查询
SQL 调试
和拦截器同时开始写。
一百五十七、Postman 测试顺序
1. POST /auth/login
2. 复制 Token
3. Authorization Bearer Token
4. GET /users
5. POST /users
6. PUT /users/{id}
7. DELETE /users/{id}
8. 分配角色
9. 分配权限
10. 换不同用户测试 403
一百五十八、测试管理员
管理员应该:
user:list
user:add
user:update
user:delete
全部通过。
一百五十九、测试普通用户
普通用户只有:
user:list
访问:
GET /api/users
成功。
访问:
DELETE /api/users/1
应该:
403
一百六十、测试未登录用户
没有 Token:
GET /api/users
应该:
401
一百六十一、测试禁用用户
用户:
status=0
登录:
拒绝
一百六十二、测试角色变更
给用户:
新增 ADMIN 角色
再次获取权限:
应该拥有更多权限
如果使用缓存:
记得刷新缓存
一百六十三、常见错误:用户有角色但没权限
排查:
sys_user_role
是否有关系
sys_role_permission
是否有关系
sys_permission
是否存在
SQL JOIN 是否正确
一百六十四、常见错误:分配角色后原角色丢了
如果业务是:
追加角色
就不能直接:
delete all
然后 insert
当前案例采用:
整体替换角色集合
所以 API 语义要明确。
一百六十五、常见错误:权限修改不生效
如果使用:
Token 内嵌权限
Redis 权限缓存
修改权限后:
旧数据还在
需要:
重新登录
或清缓存
一百六十六、常见错误:用户密码泄露
检查:
UserVO
日志
接口响应
异常信息
绝不能包含:
password
passwordHash
一百六十七、常见错误:Controller 直接返回 User Entity
因为:
User Entity 含 password
Jackson 会自动:
序列化
非常危险。
一百六十八、常见错误:权限只做前端按钮隐藏
用户可以:
Postman
直接调用。
必须:
后端拦截
一百六十九、常见错误:所有权限都判断 ADMIN
这样系统退化成:
基于角色判断
而不是:
基于权限
RBAC 的价值就下降。
一百七十、常见错误:事务方法异常被吃掉
例如:
@Transactional
public void assignRoles() {
try {
...
} catch (
Exception e
) {
System.out.println(
e.getMessage()
);
}
}
如果异常被吞掉:
Spring 可能认为方法正常结束
事务可能:
不回滚
一百七十一、正确做法
要么:
让 RuntimeException 抛出去
要么:
明确设置 rollback
初学阶段优先:
不要吞异常
一百七十二、常见错误:@Transactional 方法自己调自己
例如:
public void a() {
this.b();
}
@Transactional
public void b() {
}
某些代理模式下:
事务可能失效
因为:
没有经过 Spring 代理
一百七十三、这和 AOP 有什么关系
@Transactional 本质常依赖:
Spring AOP 代理
后面事务/AOP 章节会系统讲。
一百七十四、常见错误:权限注解读取不到
检查:
@Retention(RUNTIME)
@Target(METHOD)
如果 Retention 不是 RUNTIME:
运行时反射拿不到
一百七十五、常见错误:拦截器没注册
写了:
AuthInterceptor
但没:
WebMvcConfigurer.addInterceptors
它不会自动工作。
一百七十六、常见错误:登录接口也被拦截
要:
excludePathPatterns
排除:
/api/auth/login
一百七十七、常见错误:Authorization 解析
格式通常:
Bearer 空格 Token
不要忘:
Bearer 后有一个空格
一百七十八、常见错误:401 和 403 混乱
推荐:
Token 缺失/无效
→ 401
Token 有效但无权限
→ 403
一百七十九、常见错误:角色权限关系重复
中间表使用:
联合主键
可以直接从数据库防止重复。
一百八十、常见错误:删除角色留下脏关系
删除:
sys_role
前要处理:
sys_user_role
sys_role_permission
否则会留下:
无效关联记录
一百八十一、外键是否必须
学习项目可以:
加外键
保证引用完整性。
很多企业项目也可能:
不使用物理外键
改为:
Service 维护关系
关键是:
不能留下脏数据
一百八十二、索引设计
建议:
sys_user.username
UNIQUE
sys_role.role_code
UNIQUE
sys_permission.permission_code
UNIQUE
中间表:
PRIMARY KEY(user_id, role_id)
PRIMARY KEY(role_id, permission_id)
一百八十三、中间表反向查询索引
如果经常:
根据 role_id 查用户
user_role 联合主键:
(user_id, role_id)
不满足最左前缀:
role_id
可考虑额外:
CREATE INDEX idx_user_role_role_id
ON sys_user_role(role_id);
一百八十四、为什么这里用到了最左前缀
索引:
(user_id, role_id)
容易支持:
WHERE user_id = ?
但:
WHERE role_id = ?
不能充分利用最左列。
所以:
需要额外考虑 role_id 索引
一百八十五、role_permission 同理
主键:
(role_id, permission_id)
适合:
WHERE role_id = ?
如果频繁:
WHERE permission_id = ?
可额外:
permission_id 索引
一百八十六、RBAC 与 MySQL 进阶联系
这个案例同时实践:
联合索引
最左前缀
JOIN
事务
唯一约束
批量插入
分页
动态 SQL
一百八十七、RBAC 与反射注解联系
@RequirePermission:
自定义注解
拦截器:
反射读取注解
这就是前面:
注解 + 反射
真正进入项目。
一百八十八、RBAC 与 AOP 联系
权限控制:
可以用 Interceptor
也可以用 AOP
事务:
@Transactional
也建立在:
AOP 思想
上。
一百八十九、RBAC 与 Redis 联系
以后 Redis 可以缓存:
Token
当前用户
权限集合
验证码
一百九十、RBAC 与 Spring Security 联系
真正企业项目更常使用:
Spring Security
提供:
认证
授权
SecurityContext
Filter Chain
PasswordEncoder
方法权限
本案例手写 RBAC:
目的不是替代 Spring Security
而是:
先理解底层原理
一百九十一、为什么先手写权限再学 Security
如果直接上:
SecurityFilterChain
Authentication
GrantedAuthority
很容易只会:
复制配置
手写一次以后会明白:
Token
用户
角色
权限
拦截
401
403
到底怎么联系。
一百九十二、IDEA 快捷方式整理
创建类:
Alt + Insert
生成 Getter/Setter:
Alt + Insert
实现接口方法:
Ctrl + I
重写方法:
Ctrl + O
抽取方法:
Ctrl + Alt + M
包围 try/catch:
Ctrl + Alt + T
自动补全:
Ctrl + Space
快速修复:
Alt + Enter
一百九十三、开发时不要一次写完全部
建议:
数据库
↓
Mapper
↓
Service
↓
Controller
↓
Postman
每层都测。
一百九十四、第一天目标
只完成:
建库
User CRUD
Role CRUD
一百九十五、第二天目标
完成:
用户分配角色
角色分配权限
查询用户最终权限
一百九十六、第三天目标
完成:
登录
Token
AuthInterceptor
RequirePermission
一百九十七、第四天目标
完成:
全局异常
参数校验
分页
接口测试
Bug 修复
一百九十八、完整测试清单
登录:
正确账号密码
错误用户名
错误密码
禁用用户
空用户名
空密码
用户:
分页查询
模糊搜索
新增
重复用户名
修改
删除不存在用户
分配角色
角色:
查询
新增
重复 roleCode
修改
删除
分配权限
权限:
查询权限
查询权限树
按用户查询最终权限
鉴权:
无 Token
错误 Token
过期 Token
有权限
无权限
事务:
分配角色中途异常
确认旧关系是否回滚
删除角色中途异常
确认关联关系是否回滚
一百九十九、必须掌握的数据库关系
User
N:M
Role
Role
N:M
Permission
中间表:
sys_user_role
sys_role_permission
二百、必须掌握的 RBAC 流程
登录
↓
识别 User
↓
查询 Role
↓
查询 Permission
↓
生成登录凭证
↓
请求带 Token
↓
解析 User
↓
检查接口权限
↓
允许 / 403
二百零一、必须掌握的 SpringBoot 技术点
@RestController
@RequestMapping
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@RequestBody
@PathVariable
@RequestParam
@Valid
@Service
@Mapper
@Transactional
@RestControllerAdvice
@ExceptionHandler
二百零二、必须掌握的 MyBatis 技术点
Mapper
Mapper XML
#{}
@Param
resultType
动态 SQL
foreach
批量插入
JOIN
分页
事务
二百零三、必须掌握的安全原则
密码不能明文存储
密码不能返回前端
Token 不放敏感信息
未登录返回 401
无权限返回 403
权限必须后端校验
动态 SQL 防 SQL 注入
日志不打印密码/Token
二百零四、必须回答的问题
学完以后应该能回答:
1. RBAC 是什么?
2. User、Role、Permission 是什么关系?
3. 为什么需要 user_role 中间表?
4. 为什么需要 role_permission 中间表?
5. 为什么用户不直接绑定所有权限?
6. 认证和授权有什么区别?
7. 登录属于认证还是授权?
8. 权限校验属于认证还是授权?
9. 401 和 403 有什么区别?
10. 为什么密码不能明文保存?
11. BCrypt 的基本思想是什么?
12. JWT 是加密吗?
13. Token 一般放在哪个请求头?
14. 为什么 JWT Payload 不能放敏感数据?
15. 权限码为什么推荐 user:add 这种形式?
16. 为什么不推荐写死 ADMIN 才能删除?
17. @RequirePermission 是怎么实现的?
18. 拦截器如何拿到 Controller 方法注解?
19. 为什么 Spring Controller 成员变量不能保存当前用户?
20. 为什么分配角色必须使用事务?
21. 为什么分配权限必须使用事务?
22. 为什么不能只在前端隐藏按钮?
23. 用户权限 SQL 为什么需要多表 JOIN?
24. 用户分页为什么不推荐直接 JOIN 所有角色权限?
25. 什么是 RBAC 中的 N+1?
26. 权限缓存为什么要考虑失效?
27. 为什么删除角色需要处理中间表?
28. user_role 为什么可能需要 role_id 单列索引?
29. RBAC 和最左前缀有什么联系?
30. 为什么手写 RBAC 后再学 Spring Security 更容易?
二百零五、RBAC 知识结构
RBAC
│
├─ User
│ ├─ 登录
│ ├─ CRUD
│ └─ 分配 Role
│
├─ Role
│ ├─ CRUD
│ └─ 分配 Permission
│
├─ Permission
│ ├─ API
│ ├─ Menu
│ └─ Button
│
├─ Relation
│ ├─ user_role
│ └─ role_permission
│
├─ Authentication
│ ├─ username/password
│ ├─ Token
│ └─ LoginUser
│
├─ Authorization
│ ├─ permissionCode
│ ├─ RequirePermission
│ └─ Interceptor
│
├─ SpringBoot
│ ├─ Controller
│ ├─ Service
│ ├─ Validation
│ ├─ Transaction
│ └─ Exception
│
├─ MyBatis
│ ├─ Mapper
│ ├─ Dynamic SQL
│ ├─ foreach
│ └─ JOIN
│
└─ Security
├─ Password Hash
├─ 401
├─ 403
├─ SQL Injection
└─ Permission Check
二百零六、本章总结
RBAC 最核心的一句话:
用户通过角色获得权限。
核心关系:
User
↓
user_role
↓
Role
↓
role_permission
↓
Permission
登录解决:
你是谁
权限解决:
你能做什么
也就是:
Authentication
认证
Authorization
授权
数据库核心:
sys_user
sys_role
sys_permission
sys_user_role
sys_role_permission
SpringBoot 核心:
Controller
Service
Mapper
DTO
VO
Validation
Transaction
Global Exception
鉴权核心:
Token
↓
LoginUser
↓
permissionCode
↓
@RequirePermission
↓
Interceptor
安全上一定记住:
密码不能明文
密码不能返回前端
权限不能只靠前端按钮
未登录 401
无权限 403
事务异常不要吞
权限变更要处理缓存
这个案例最重要的价值不是“做出一个后台”,而是把前面的:
SpringBoot 请求响应
RESTful
MyBatis
动态 SQL
JOIN
事务
索引
注解
反射
异常机制
真正组合成一个完整系统。
按照课程表,RBAC 综合案例后面仍然还有后续综合练习内容,可以继续在这个系统上补:
完整角色权限页面接口
登录鉴权完善
菜单权限树
统一异常
事务与 AOP
然后再进入:
事务管理 / AOP 思想