SpringBoot_MyBatis_RBAC权限系统综合实战

O泡李华 7

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 思想