RBAC权限系统第三阶段_异常校验JWT缓存日志AOP详解

O泡李华 6

RBAC 权限系统第三阶段:统一异常、参数校验、JWT 封装、权限缓存、操作日志与项目完善

本章位置:第二阶段 Java 核心框架
对应课程:综合案例:RBAC 权限系统(第三阶段)
前置知识:RBAC 第一阶段、RBAC 第二阶段、SpringBoot、MyBatis、RESTful、事务、注解、反射
本章目标:把前两阶段已经完成的 RBAC 系统进一步规范化,补齐统一异常、参数校验、JWT 封装、权限缓存、登录状态、操作日志、审计、AOP 应用、接口规范和项目收尾。


一、为什么还需要第三阶段

前两阶段已经完成:

用户

角色

权限

用户-角色

角色-权限

登录

Token

Interceptor

@RequirePermission

菜单权限树

401

403

功能上已经基本能用。

但是如果作为一个比较完整的企业项目,还会出现:

每个 Controller 都自己处理异常怎么办?

参数校验失败怎么统一返回?

JWT 代码散落在项目里怎么办?

每个请求都查数据库权限会不会慢?

管理员修改权限后缓存怎么失效?

谁删除了用户怎么查?

谁修改了角色权限怎么审计?

接口日志怎么统一记录?

异常日志怎么统一记录?

权限校验能不能用 AOP 实现?

返回 JSON 格式怎么统一?

第三阶段主要解决这些:

规范

可维护性

可观测性

性能

安全

工程化

二、第三阶段知识结构

RBAC 第三阶段
│
├─ 统一响应
│  ├─ Result<T>
│  ├─ ErrorCode
│  └─ HTTP Status
│
├─ 参数校验
│  ├─ @Valid
│  ├─ @Validated
│  ├─ @NotBlank
│  ├─ @NotNull
│  ├─ @Size
│  ├─ @Min
│  └─ @Max
│
├─ 异常体系
│  ├─ BusinessException
│  ├─ UnauthorizedException
│  ├─ ForbiddenException
│  ├─ Validation Exception
│  └─ GlobalExceptionHandler
│
├─ JWT
│  ├─ create
│  ├─ parse
│  ├─ expire
│  ├─ secret
│  └─ refresh
│
├─ Cache
│  ├─ LoginUser
│  ├─ Permission
│  ├─ Redis
│  └─ Invalidation
│
├─ Operation Log
│  ├─ @OperationLog
│  ├─ AOP
│  ├─ userId
│  ├─ module
│  └─ result
│
└─ Project Quality
   ├─ DTO/VO
   ├─ Security
   ├─ SQL
   ├─ Transaction
   ├─ Logging
   └─ Testing

三、统一响应为什么重要

如果不同接口返回:

接口 A:

{
    "code": 200,
    "data": {}
}

接口 B:

{
    "success": true,
    "message": "ok"
}

接口 C:

{
    "status": 0,
    "result": {}
}

前端会非常难处理。

所以项目应该统一:

code

message

data

四、Result

推荐:

public class Result<T> {

    private String code;

    private String message;

    private T data;

    public Result() {

    }

    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;

        result.data =
                null;

        return result;
    }

    // Getter / Setter
}

五、为什么 code 推荐 String

简单系统可以:

10001

10002

大型系统更直观可以:

USER_NOT_FOUND

ROLE_NOT_FOUND

FORBIDDEN

TOKEN_EXPIRED

优点:

可读性更好

六、ErrorCode 枚举

可以统一定义:

public enum ErrorCode {

    SUCCESS(
            "SUCCESS",
            "success"
    ),

    BAD_REQUEST(
            "BAD_REQUEST",
            "请求参数错误"
    ),

    UNAUTHORIZED(
            "UNAUTHORIZED",
            "请先登录"
    ),

    FORBIDDEN(
            "FORBIDDEN",
            "无权访问"
    ),

    USER_NOT_FOUND(
            "USER_NOT_FOUND",
            "用户不存在"
    ),

    USER_EXISTS(
            "USER_EXISTS",
            "用户名已存在"
    ),

    ROLE_NOT_FOUND(
            "ROLE_NOT_FOUND",
            "角色不存在"
    );

    private final String code;

    private final String message;

    ErrorCode(
            String code,
            String message
    ) {

        this.code =
                code;

        this.message =
                message;
    }
}

七、为什么 ErrorCode 有价值

避免项目里到处:

throw new BusinessException(
        "USER_NOT_FOUND",
        "用户不存在"
);

可以:

throw new BusinessException(
        ErrorCode.USER_NOT_FOUND
);

减少:

拼写错误

错误码不统一

重复字符串

八、BusinessException

public class BusinessException
        extends RuntimeException {

    private final ErrorCode
            errorCode;

    public BusinessException(
            ErrorCode errorCode
    ) {

        super(
                errorCode.getMessage()
        );

        this.errorCode =
                errorCode;
    }

    public BusinessException(
            ErrorCode errorCode,
            String message
    ) {

        super(
                message
        );

        this.errorCode =
                errorCode;
    }

    public ErrorCode getErrorCode() {

        return errorCode;
    }
}

九、为什么业务异常继承 RuntimeException

Spring 事务默认常见行为:

RuntimeException
→ rollback

所以业务异常通常:

继承 RuntimeException

更容易和:

事务

配合。


十、异常不要在 Controller 到处 try/catch

不推荐:

@PostMapping
public Result<?> create(
        @RequestBody
        UserCreateDTO dto
) {

    try {

        return Result.success(
                userService.create(
                        dto
                )
        );

    } catch (
            BusinessException e
    ) {

        return Result.fail(
                e.getCode(),
                e.getMessage()
        );
    }
}

每个 Controller 都重复:

try/catch

会很乱。


十一、GlobalExceptionHandler

推荐:

@RestControllerAdvice
public class GlobalExceptionHandler {

}

十二、处理 BusinessException

@ExceptionHandler(
        BusinessException.class
)
public ResponseEntity<Result<Void>>
handleBusinessException(
        BusinessException e
) {

    return ResponseEntity
            .badRequest()
            .body(
                    Result.fail(
                            e.getErrorCode()
                                    .getCode(),
                            e.getMessage()
                    )
            );
}

十三、不同业务异常应该使用不同 HTTP Status 吗

可以。

例如:

USER_NOT_FOUND
→ 404

USER_EXISTS
→ 409

UNAUTHORIZED
→ 401

FORBIDDEN
→ 403

比所有业务异常:

400

语义更清晰。


十四、UnauthorizedException

public class UnauthorizedException
        extends RuntimeException {

    public UnauthorizedException(
            String message
    ) {

        super(
                message
        );
    }
}

十五、ForbiddenException

public class ForbiddenException
        extends RuntimeException {

    public ForbiddenException(
            String message
    ) {

        super(
                message
        );
    }
}

十六、统一 401

@ExceptionHandler(
        UnauthorizedException.class
)
public ResponseEntity<Result<Void>>
handleUnauthorized(
        UnauthorizedException e
) {

    return ResponseEntity
            .status(
                    HttpStatus.UNAUTHORIZED
            )
            .body(
                    Result.fail(
                            "UNAUTHORIZED",
                            e.getMessage()
                    )
            );
}

十七、统一 403

@ExceptionHandler(
        ForbiddenException.class
)
public ResponseEntity<Result<Void>>
handleForbidden(
        ForbiddenException e
) {

    return ResponseEntity
            .status(
                    HttpStatus.FORBIDDEN
            )
            .body(
                    Result.fail(
                            "FORBIDDEN",
                            e.getMessage()
                    )
            );
}

十八、参数校验为什么要统一

如果每个 Service 都写:

if (
    dto.getUsername() == null
    ||
    dto.getUsername().isBlank()
) {

}

大量重复。

SpringBoot 可以使用:

Bean Validation

十九、SpringBoot 3 Validation 包

常见:

jakarta.validation.Valid

jakarta.validation.constraints.NotBlank

jakarta.validation.constraints.NotNull

注意:

SpringBoot 3
使用 jakarta.*

二十、@NotBlank

适合:

String

要求:

不能 null

不能 ""

不能全空格

例如:

@NotBlank(
        message = "用户名不能为空"
)
private String username;

二十一、@NotNull

要求:

不能 null

例如:

@NotNull(
        message = "状态不能为空"
)
private Integer status;

二十二、@Size

例如:

@Size(
        min = 4,
        max = 20,
        message = "用户名长度必须在 4~20 之间"
)
private String username;

二十三、@Min / @Max

@Min(
        value = 0,
        message = "状态值错误"
)
@Max(
        value = 1,
        message = "状态值错误"
)
private Integer status;

二十四、集合校验

例如:

@NotEmpty(
        message = "角色列表不能为空"
)
private List<Long> roleIds;

二十五、@Valid

Controller:

@PostMapping
public Result<Long> create(
        @Valid
        @RequestBody
        UserCreateDTO dto
) {

    return Result.success(
            userService.create(
                    dto
            )
    );
}

二十六、@Valid 的作用

请求进入 Controller 前:

自动校验 DTO

如果失败:

抛出校验异常

二十七、MethodArgumentNotValidException

@RequestBody + @Valid 校验失败常见:

MethodArgumentNotValidException

二十八、统一处理校验错误

@ExceptionHandler(
        MethodArgumentNotValidException.class
)
public ResponseEntity<Result<Void>>
handleValidation(
        MethodArgumentNotValidException e
) {

    String message =
            e.getBindingResult()
                    .getFieldErrors()
                    .stream()
                    .findFirst()
                    .map(
                            FieldError::getDefaultMessage
                    )
                    .orElse(
                            "参数校验失败"
                    );

    return ResponseEntity
            .badRequest()
            .body(
                    Result.fail(
                            "VALIDATION_FAILED",
                            message
                    )
            );
}

二十九、为什么只返回第一个错误

学习阶段:

简单

前端一次只提示一个。

更完整也可以返回:

全部字段错误

三十、返回全部校验错误

例如:

{
    "username": "用户名不能为空",
    "password": "密码长度不能少于 6 位"
}

更适合:

复杂表单

三十一、@Validated

可以用于:

方法参数校验

分组校验

例如 Controller 类:

@Validated
@RestController
public class UserController {

}

三十二、PathVariable 校验

例如:

@GetMapping(
        "/{id}"
)
public Result<?> detail(
        @Min(
                value = 1,
                message = "id 必须大于 0"
        )
        @PathVariable
        Long id
) {

}

通常需要:

@Validated

三十三、分组校验简单了解

例如:

新增
用户名必填

修改
用户名不允许改

可以使用:

Validation Groups

但学习项目更推荐:

CreateDTO

UpdateDTO

分开,更清晰。


三十四、DTO 分开通常更好

UserCreateDTO

UserUpdateDTO

UserQueryDTO

AssignRoleDTO

比:

一个 UserDTO
塞所有场景

更清晰。


三十五、JWT 封装为什么要独立

如果 Controller / Interceptor 到处写:

JWT parser

secret

expire

claims

代码会分散。

应该集中:

TokenUtil

或:

JwtService

三十六、JWT 配置不要硬编码

错误:

private static final String SECRET =
        "123456";

推荐放:

application.yml

三十七、application.yml

例如:

security:
  jwt:
    secret: ${JWT_SECRET:change-me-in-production}
    expire-minutes: 120

三十八、为什么 secret 不应该提交真实生产值

Git 仓库如果保存:

真实密钥

泄露后:

攻击者可能伪造 Token

生产环境应该:

环境变量

Secret Manager

容器 Secret

三十九、配置类

@ConfigurationProperties(
        prefix = "security.jwt"
)
public class JwtProperties {

    private String secret;

    private Long expireMinutes;

    // Getter / Setter
}

四十、TokenUtil 职责

应该只做:

生成

解析

校验

过期判断

不要做:

查数据库

查角色

查权限

四十一、Token Claims

学习阶段最少放:

userId

还可以:

username

但是:

权限建议不放

四十二、生成 Token 思路

userId
↓
当前时间
↓
过期时间
↓
Claims
↓
签名
↓
JWT String

四十三、解析 Token 思路

Token
↓
验证签名
↓
验证 exp
↓
读取 Claims
↓
取 userId

四十四、JWT 异常类型

常见:

过期

格式错误

签名错误

Token 缺失

统一:

401

即可。


四十五、不要把 JWT 库异常直接返回前端

例如不要:

{
    "message": "io.jsonwebtoken.security.SignatureException..."
}

应该:

{
    "code": "TOKEN_INVALID",
    "message": "登录状态无效"
}

四十六、Access Token 生命周期

例如:

120 分钟

到期:

401

前端:

跳登录

四十七、Refresh Token 是否必须

毕业设计 / 学习案例:

不是必须

先把:

Access Token

做正确。

真实复杂系统再考虑:

Refresh Token

四十八、登录用户缓存为什么需要

前面每个请求:

Token
↓
userId
↓
查 User
↓
查 Role
↓
查 Permission

如果请求量很大:

数据库压力高

所以可以:

缓存 LoginUser

四十九、推荐缓存 Key

例如:

rbac:login:user:{userId}

或者:

rbac:permissions:{userId}

五十、缓存内容

可以缓存:

{
    "userId": 1,
    "username": "admin",
    "roleCodes": [
        "ADMIN"
    ],
    "permissionCodes": [
        "user:list",
        "user:add"
    ]
}

五十一、Redis 阶段尚未正式学习怎么办

当前 RBAC 第三阶段可以:

理解设计

甚至先写接口:

LoginUserCacheService

内部先:

ConcurrentHashMap

模拟。

后面 Redis 阶段:

替换成 Redis

五十二、为什么不要直接把 Map 写死在 Interceptor

推荐抽象:

public interface LoginUserCacheService {

    LoginUser get(
            Long userId
    );

    void put(
            LoginUser loginUser
    );

    void remove(
            Long userId
    );
}

五十三、本地缓存实现

@Component
public class LocalLoginUserCacheService
        implements LoginUserCacheService {

    private final ConcurrentHashMap
            <Long, LoginUser>
            cache =
            new ConcurrentHashMap<>();

    @Override
    public LoginUser get(
            Long userId
    ) {

        return cache.get(
                userId
        );
    }

    @Override
    public void put(
            LoginUser loginUser
    ) {

        cache.put(
                loginUser.getUserId(),
                loginUser
        );
    }

    @Override
    public void remove(
            Long userId
    ) {

        cache.remove(
                userId
        );
    }
}

五十四、本地缓存有什么问题

如果部署两个实例:

Server A

Server B

A 的内存:

B 看不到

所以分布式部署:

更适合 Redis

五十五、本地缓存适合什么

学习

单机项目

临时验证

不适合:

多实例共享登录状态

五十六、缓存读取流程

Token
↓
userId
↓
Cache.get(userId)
├─ 有 → 直接使用
└─ 无
   ↓
   DB 查询
   ↓
   构造 LoginUser
   ↓
   put cache

这叫:

Cache Aside
旁路缓存

五十七、Cache Aside 读流程

先查缓存

未命中
↓
查数据库
↓
写缓存

五十八、权限缓存什么时候失效

必须考虑:

用户角色修改

角色权限修改

用户禁用

角色禁用

权限禁用

用户删除

五十九、用户角色变更

@Transactional
public void assignRoles(
        Long userId,
        List<Long> roleIds
) {

    // DB 更新

    loginUserCacheService.remove(
            userId
    );
}

注意:

缓存删除应该在数据库事务真正成功后更稳妥

这个问题后面事务/AOP 会进一步讨论。


六十、为什么事务没提交就删缓存有风险

流程:

删缓存
↓
数据库事务最后回滚

结果:

数据库没变

缓存却被删

虽然下次会重新加载正确数据,

通常只是:

缓存未命中

问题不大。

但复杂场景:

事务与缓存一致性

需要更细致设计。


六十一、角色权限变更

例如:

roleId=1

权限改变。

影响:

所有拥有 roleId=1 的用户

所以:

查 userIds
↓
逐个删除缓存

六十二、查询受影响用户

SELECT user_id
FROM sys_user_role
WHERE role_id = #{roleId};

六十三、批量失效

for (
        Long userId :
        userIds
) {

    loginUserCacheService.remove(
            userId
    );
}

以后 Redis:

pipeline

批量删除

进一步优化。


六十四、用户禁用

status = 0

必须:

清当前 LoginUser 缓存

否则缓存中的:

status=1

可能还继续放行。


六十五、用户删除

必须:

删除 user_role

删除 user

清缓存

六十六、Token 和 LoginUser 缓存的关系

Token:

证明请求对应 userId

缓存:

快速获得 userId 当前权限

二者职责不同。


六十七、Token 仍有效但缓存没有

没问题:

重新查数据库

六十八、Token 仍有效但用户被禁用

数据库或缓存失效后重新加载:

发现 status=0
↓
拒绝

六十九、操作日志为什么重要

管理员系统中必须知道:

谁

什么时候

做了什么

例如:

admin
2026-09-10 16:20
删除用户 id=10

七十、操作日志常见用途

审计

问题追踪

安全分析

误操作定位

责任追踪

七十一、sys_operation_log

可以设计:

CREATE TABLE sys_operation_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT,
    username VARCHAR(50),
    module VARCHAR(50),
    operation VARCHAR(100),
    request_method VARCHAR(10),
    request_uri VARCHAR(255),
    request_ip VARCHAR(64),
    success TINYINT NOT NULL,
    error_message VARCHAR(500),
    cost_ms BIGINT,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

七十二、为什么不要把完整请求体都存日志

请求可能包含:

密码

Token

身份证

大文件

大量 JSON

所以日志记录要:

脱敏

限制长度

选择字段

七十三、绝对不要记录 password

例如登录:

{
    "username": "admin",
    "password": "123456"
}

操作日志不应该:

完整保存 Body

七十四、自定义 @OperationLog

@Target(
        ElementType.METHOD
)
@Retention(
        RetentionPolicy.RUNTIME
)
public @interface OperationLog {

    String module();

    String operation();
}

七十五、使用示例

@DeleteMapping(
        "/{id}"
)
@RequirePermission(
        "user:delete"
)
@OperationLog(
        module = "用户管理",
        operation = "删除用户"
)
public ResponseEntity<Void>
delete(
        @PathVariable
        Long id
) {

    userService.deleteById(
            id
    );

    return ResponseEntity
            .noContent()
            .build();
}

七十六、为什么操作日志适合 AOP

如果每个 Controller 都写:

开始时间

当前用户

IP

执行方法

耗时

成功/失败

写数据库

代码重复。

AOP 可以:

统一拦截带 @OperationLog 的方法

七十七、AOP 是什么

AOP:

Aspect-Oriented Programming

面向切面编程。

简单理解:

把很多业务都需要的公共逻辑,从业务代码中抽出来统一处理。

例如:

日志

事务

权限

性能统计

七十八、Aspect

定义:

@Aspect
@Component
public class OperationLogAspect {

}

七十九、Around

可以:

@Around(
        "@annotation(operationLog)"
)

拦截:

带 @OperationLog 的方法

八十、Around 伪代码

@Around(
        "@annotation(operationLog)"
)
public Object around(
        ProceedingJoinPoint joinPoint,
        OperationLog operationLog
)
        throws Throwable {

    long start =
            System.currentTimeMillis();

    boolean success =
            false;

    String errorMessage =
            null;

    try {

        Object result =
                joinPoint.proceed();

        success =
                true;

        return result;

    } catch (
            Throwable e
    ) {

        errorMessage =
                e.getMessage();

        throw e;

    } finally {

        long cost =
                System.currentTimeMillis()
                - start;

        // 写操作日志
    }
}

八十一、joinPoint.proceed()

表示:

真正执行原 Controller 方法

如果不调用:

业务方法不会执行

八十二、Around 能拿到什么

可以获得:

方法

参数

目标类

注解

执行结果

异常

耗时

八十三、为什么 AOP 不应该修改业务结果

日志切面主要:

观察
记录

尽量不要:

偷偷改变业务返回值

否则业务行为很难理解。


八十四、操作日志失败能不能影响业务

通常不建议:

用户删除成功

但日志数据库插入失败
↓
整个删除回滚

是否需要强一致:

看业务

很多系统更倾向:

日志失败不影响主业务

后续可以:

异步消息

记录日志。


八十五、当前学习项目如何处理

简单做法:

日志插入 try/catch

失败打印 error

不影响主业务

注意:

不要吞主业务异常

八十六、主业务异常必须继续抛出

切面:

catch (
        Throwable e
) {

    errorMessage =
            e.getMessage();

    throw e;
}

必须:

throw e

八十七、如果切面吞异常会发生什么

可能造成:

Controller 看起来成功

事务无法正确感知异常

错误状态被隐藏

八十八、操作日志保存当前用户

可以从:

request attribute loginUser

获取。

或者以后:

SecurityContext

八十九、获取 Request

AOP 中可通过:

RequestContextHolder

拿当前 Request。

但要避免:

业务层强耦合太多 Web 细节

操作日志切面属于 Web 审计:

可以接受

九十、记录 IP

可以读取:

request.getRemoteAddr()

经过代理时:

可能要解析 X-Forwarded-For

但不能无脑信任外部 Header。

学习阶段:

先了解

九十一、记录耗时

long costMs =
        System.currentTimeMillis()
        - start;

用于发现:

慢接口

九十二、是否所有 GET 都记录数据库日志

如果列表查询频繁:

每次都插 operation_log

日志量会很大。

常见:

重点记录修改型操作

例如:

新增

修改

删除

分配权限

登录

九十三、登录日志和操作日志可分开

登录日志:

认证安全

操作日志:

业务审计

可以分别:

sys_login_log

sys_operation_log

九十四、sys_login_log

示例:

CREATE TABLE sys_login_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT,
    username VARCHAR(50),
    login_ip VARCHAR(64),
    user_agent VARCHAR(500),
    success TINYINT NOT NULL,
    message VARCHAR(255),
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

九十五、登录失败也要记录吗

可以。

例如:

用户名

IP

失败原因(不要过于详细)

时间

用于:

暴力破解分析

九十六、登录失败原因不要返回得太细

前端:

用户名或密码错误

内部日志可以区分:

USER_NOT_FOUND

PASSWORD_MISMATCH

但注意:

日志权限

九十七、权限校验用 Interceptor 还是 AOP

都可以实现一部分。

本项目已经使用:

Interceptor

它的优势:

在 Controller 前

容易拿 Request

容易做登录认证

容易拿 HandlerMethod

九十八、AOP 权限优势

可以:

直接按注解切入方法

例如:

@Before(
    "@annotation(requirePermission)"
)

九十九、为什么登录认证更适合 Filter / Interceptor

认证属于:

请求级基础设施

还没进入 Controller 时就应该判断。


一百、为什么方法权限可以用 AOP

方法权限和:

具体方法注解

关系紧密。

例如 Spring Security:

@PreAuthorize

本质上就是:

声明式方法授权

一百零一、本案例推荐分工

AuthInterceptor
负责:
Token
登录身份

@RequirePermission
+
Interceptor
负责:
接口权限

AOP
负责:
操作日志
性能日志
审计

这样层次清晰。


一百零二、不要同时做两套权限系统

不要:

Interceptor 判断一套

AOP 又判断另一套

否则:

行为重复

排错困难

学习项目保持:

一套主流程

一百零三、日志框架

不要长期使用:

System.out.println();

SpringBoot 常用:

SLF4J
+
Logback

一百零四、Logger

private static final Logger log =
        LoggerFactory.getLogger(
                UserService.class
        );

一百零五、日志级别

常见:

TRACE

DEBUG

INFO

WARN

ERROR

一百零六、DEBUG

适合:

开发调试细节

生产环境通常:

不会大量开启

一百零七、INFO

记录:

重要正常流程

例如:

应用启动

关键任务完成

一百零八、WARN

表示:

出现异常情况
但系统还能继续

一百零九、ERROR

表示:

严重错误

通常伴随:

Exception

一百一十、日志不要字符串拼接

不推荐:

log.info(
        "userId="
        + userId
);

推荐:

log.info(
        "userId={}",
        userId
);

一百一十一、为什么占位符更好

性能更合理

格式统一

更易读

一百一十二、日志脱敏

例如手机号:

138****8000

身份证:

只显示部分

Token:

最多打印 hash / 前几位

一百一十三、密码永远不打印

包括:

DEBUG

ERROR

请求 Body

AOP 参数日志

都不要记录密码。


一百一十四、Request DTO 和 Response VO 最终规范

建议:

entity
数据库对象

dto
请求输入

vo
响应输出

一百一十五、User Entity

password
deleted
createTime
updateTime

都可能存在。


一百一十六、UserVO

只返回:

id

username

nickname

status

roleCodes

一百一十七、MapStruct 简单预览

对象转换很多时:

Entity → VO

DTO → Entity

可以使用:

MapStruct

自动生成映射代码。

当前:

了解即可

一百一十八、学习阶段手工转换更有价值

例如:

private UserVO toVO(
        User user
) {

    UserVO vo =
            new UserVO();

    vo.setId(
            user.getId()
    );

    vo.setUsername(
            user.getUsername()
    );

    return vo;
}

先理解:

数据边界

更重要。


一百一十九、统一分页对象

public class PageResult<T> {

    private List<T> records;

    private Long total;

    private Integer pageNum;

    private Integer pageSize;

    private Integer totalPages;

}

一百二十、分页参数规范

建议:

pageNum
从 1 开始

pageSize
默认 10

最大 100

一百二十一、为什么限制 pageSize

用户可以恶意:

pageSize=1000000

造成:

大查询

高内存

慢响应

一百二十二、UserQueryDTO

public class UserQueryDTO {

    private String keyword;

    @Min(
            value = 1,
            message = "pageNum 必须大于 0"
    )
    private Integer pageNum =
            1;

    @Min(
            value = 1,
            message = "pageSize 必须大于 0"
    )
    @Max(
            value = 100,
            message = "pageSize 不能超过 100"
    )
    private Integer pageSize =
            10;

}

一百二十三、Controller 查询

@GetMapping
@RequirePermission(
        "user:list"
)
public Result<PageResult<UserVO>>
list(
        @Valid
        UserQueryDTO query
) {

    return Result.success(
            userService.findPage(
                    query
            )
    );
}

一百二十四、为什么 GET DTO 不加 @RequestBody

GET 查询参数来自:

Query String

Spring 可以:

自动绑定 JavaBean

一百二十五、统一 Controller 风格

推荐:

Controller 尽量薄

例如:

@PostMapping
public Result<Long> create(
        @Valid
        @RequestBody
        UserCreateDTO dto
) {

    return Result.success(
            userService.create(
                    dto
            )
    );
}

一百二十六、业务判断不要放 Controller

例如:

用户名是否存在

角色是否存在

用户能否删除自己

内置管理员能否删除

放:

Service

一百二十七、禁止删除当前自己

RBAC 后台可以增加规则:

当前登录用户
不能删除自己

避免:

管理员把自己删掉

一百二十八、Service 例子

if (
    Objects.equals(
            loginUser.getUserId(),
            targetUserId
    )
) {

    throw new BusinessException(
            ErrorCode.CANNOT_DELETE_SELF
    );
}

一百二十九、是否允许删除超级管理员

一般:

不允许

可以给角色:

system_builtin = 1

或者用户:

system_builtin

保护系统基础数据。


一百三十、内置角色

例如:

ADMIN

不建议普通管理员删除。


一百三十一、Role 表增加 built_in

可以:

built_in TINYINT DEFAULT 0

删除时:

built_in=1
→ 拒绝

一百三十二、权限删除也要保护

如果权限已经被:

角色引用

删除前应该:

检查关系

或者:

事务级联删除

一百三十三、软删除还是硬删除

RBAC 管理表可以讨论:

硬删除

软删除

一百三十四、硬删除

真正:

DELETE FROM ...

优点:

干净

缺点:

无法恢复

审计困难

一百三十五、软删除

增加:

deleted

例如:

0 正常

1 删除

SQL:

UPDATE sys_user
SET deleted = 1
WHERE id = ?;

一百三十六、软删除的坑

所有查询都必须:

WHERE deleted = 0

否则:

删除用户又查出来

一百三十七、唯一索引和软删除

如果 username 有:

UNIQUE

软删除后:

相同 username
仍可能不能重新注册

这是软删除常见设计问题。


一百三十八、不要为了“企业级”强行软删除

学习项目时间有限:

硬删除也可以

重点:

理解取舍

一百三十九、数据库事务边界复查

以下操作建议事务:

创建用户 + 默认角色

删除用户 + 删除 user_role

删除角色 + user_role + role_permission

分配用户角色

分配角色权限

一百四十、创建用户自动绑定默认角色

例如:

新用户默认 USER 角色

流程:

insert sys_user
↓
获得 userId
↓
查询 USER roleId
↓
insert sys_user_role

必须:

一个事务

一百四十一、默认角色不存在怎么办

Service:

抛异常
↓
事务回滚

不能:

用户创建成功
但没有默认角色

一百四十二、事务注解位置

通常:

Service public 方法

例如:

@Transactional
public Long createUser(
        UserCreateDTO dto
) {

}

一百四十三、事务方法不要太长

不要事务里:

发邮件

调第三方 HTTP

等待文件上传

做大量 CPU 计算

事务时间越长:

锁时间越长

一百四十四、数据库操作先完成

常见思路:

事务内
只做必要 DB 操作

其他:

事务提交后
再处理

后面企业项目会进一步学:

事件

消息队列

一百四十五、操作日志和事务关系

如果日志必须:

记录“操作成功”

应该在:

业务确实成功之后

写 success=1。

异常:

success=0

一百四十六、日志写入事务是否独立

高级场景可能:

REQUIRES_NEW

让日志事务独立。

当前阶段:

知道存在这种需求即可

下一篇事务/AOP 会详细讲传播行为。


一百四十七、权限缓存与事务提交

更严谨的做法:

数据库事务提交成功
↓
再删除缓存

Spring 可以:

事务同步回调

当前:

理解顺序即可

一百四十八、接口防越权

权限通过:

不等于能操作任意资源

例如普通部门管理员:

有 user:update

但可能只能改:

自己部门用户

一百四十九、功能权限 vs 数据权限

功能权限:

能不能执行 user:update

数据权限:

能改哪些用户

这是两个层次。


一百五十、RBAC 主要解决什么

主要:

功能权限

复杂企业系统还需要:

Data Scope
数据权限

一百五十一、数据权限例子

角色:

部门管理员

拥有:

user:list

但是只能查询:

当前部门

一百五十二、数据权限实现方式

常见:

Service 拼查询条件

MyBatis 拦截器

AOP

数据权限框架

当前 RBAC 案例:

不继续复杂化

只需要知道:

RBAC ≠ 所有权限问题

一百五十三、越权漏洞

例如:

用户 A
GET /api/users/1

把 URL 改:

/api/users/2

如果后端只判断:

已登录

就可能查看其他用户数据。

这叫:

水平越权

一百五十四、垂直越权

普通用户调用:

管理员接口

属于:

垂直越权

RBAC 的:

permissionCode

主要防:

垂直越权

一百五十五、水平越权怎么防

Service 层要判断:

当前用户
是否可以访问目标资源

不能只靠:

前端隐藏

一百五十六、日志能帮助发现越权尝试

记录:

userId

path

method

403

IP

可以发现:

异常访问行为

一百五十七、接口速率限制简单预览

登录接口容易:

暴力破解

真实项目可以:

IP 限流

账号失败次数限制

验证码

临时锁定

当前:

了解即可

一百五十八、密码错误次数

例如:

连续 5 次错误
↓
账号临时锁定 10 分钟

通常需要:

Redis

更方便。


一百五十九、不要把“账号不存在”加入不同限流逻辑太明显

避免被:

用户名枚举

利用。


一百六十、CORS

前端:

localhost:5173

后端:

localhost:8080

浏览器会遇到:

跨域

一百六十一、开发环境 CORS

可以统一:

@Configuration
public class CorsConfig {

}

不要每个 Controller 都:

@CrossOrigin

一百六十二、生产环境不要无脑 *

特别是:

允许 credentials

时配置要谨慎。


一百六十三、RESTful 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

一百六十七、日志

GET /api/operation-logs

GET /api/login-logs

只允许:

有审计权限的管理员

一百六十八、日志权限码

例如:

log:operation:list

log:login:list

一百六十九、为什么日志也需要权限

日志中可能包含:

用户名

IP

接口路径

操作内容

不应该:

所有普通用户都能查看

一百七十、Swagger / OpenAPI

项目收尾时建议:

统一接口文档

可以使用:

OpenAPI
Swagger UI
Knife4j

一百七十一、接口文档应至少包含

接口名称

Method

URL

权限码

请求参数

请求 Body

响应

错误码

一百七十二、为什么要写权限码

前端和测试人员看到:

DELETE /users/{id}
需要 user:delete

更容易联调。


一百七十三、数据库索引最终检查

sys_user:

PRIMARY KEY(id)

UNIQUE(username)

一百七十四、sys_role

PRIMARY KEY(id)

UNIQUE(role_code)

一百七十五、sys_permission

PRIMARY KEY(id)

UNIQUE(permission_code)

INDEX(parent_id)

一百七十六、sys_user_role

PRIMARY KEY(user_id, role_id)

INDEX(role_id)

一百七十七、sys_role_permission

PRIMARY KEY(role_id, permission_id)

INDEX(permission_id)

一百七十八、operation_log

可考虑:

INDEX(user_id)

INDEX(create_time)

INDEX(module)

根据:

查询场景

决定。


一百七十九、为什么日志表 create_time 需要索引

日志最常见:

按时间倒序分页

例如:

ORDER BY create_time DESC
LIMIT 20;

一百八十、不要给每列都加索引

索引:

有写入成本

日志表写入频繁。

要根据:

真实查询条件

设计。


一百八十一、SQL 最终规范

尽量:

明确列名

不用 SELECT *

动态条件使用 #{}

动态排序白名单

JOIN 关联列有索引

一百八十二、RBAC 权限查询 SQL 最终版思路

SELECT DISTINCT
    p.permission_code

FROM sys_user_role ur

INNER JOIN sys_role r
    ON ur.role_id = r.id

INNER JOIN sys_role_permission rp
    ON r.id = rp.role_id

INNER JOIN sys_permission p
    ON rp.permission_id = p.id

WHERE
    ur.user_id = #{userId}

    AND r.status = 1;

一百八十三、为什么加 role.status

角色被禁用后:

不应该继续贡献权限

一百八十四、如果 Permission 有 status

还应该:

AND p.status = 1

一百八十五、用户 status 在哪检查

一般:

构造 LoginUser 前检查

如果:

status=0

直接:

认证失效

一百八十六、完整请求安全链路

Request
↓
CORS
↓
Token
↓
User Status
↓
LoginUser Cache
↓
RequirePermission
↓
Controller
↓
Service Data Check
↓
Transaction
↓
Mapper
↓
Database
↓
Operation Log
↓
Response

一百八十七、不要只做“接口权限”

还要有:

参数校验

资源存在校验

数据范围校验

事务

日志

错误处理

一百八十八、项目包结构最终建议

com.example.rbac
│
├─ controller
├─ service
├─ mapper
├─ entity
├─ dto
├─ vo
├─ common
├─ exception
├─ security
├─ config
├─ annotation
├─ aspect
├─ log
└─ cache

一百八十九、annotation

放:

RequirePermission

OperationLog

一百九十、aspect

放:

OperationLogAspect

后续可放:

DataScopeAspect

一百九十一、security

放:

TokenUtil

LoginUser

AuthInterceptor

一百九十二、cache

放:

LoginUserCacheService

LocalLoginUserCacheService

以后:

RedisLoginUserCacheService

一百九十三、log

放:

OperationLogService

LoginLogService

一百九十四、common

放:

Result

PageResult

ErrorCode

一百九十五、项目完成后的测试顺序

第一轮:

数据库 CRUD

一百九十六、第二轮

角色权限关系

一百九十七、第三轮

登录
Token
/me

一百九十八、第四轮

401
403

一百九十九、第五轮

菜单树
按钮权限

二百、测试参数校验

例如:

{
    "username": "",
    "password": ""
}

应该:

400

返回:

统一 validation JSON

二百零一、测试重复用户名

创建:

admin

两次。

第二次:

409 Conflict

更合理。


二百零二、测试用户不存在

GET /api/users/999999

应该:

404

二百零三、测试无 Token

GET /api/users

应该:

401

二百零四、测试无权限

普通用户:

DELETE /api/users/1

应该:

403

二百零五、测试角色禁用

用户有角色:

ADMIN

把:

ADMIN status=0

清缓存后:

对应权限应该消失

二百零六、测试权限变更

给角色新增:

user:delete

清相关用户缓存。

再次请求:

删除接口应该放行

二百零七、测试缓存未命中

清缓存:

请求接口

应该:

自动查 DB
重新写缓存

二百零八、测试日志

执行:

新增用户

修改用户

删除用户

分配角色

分配权限

查看:

sys_operation_log

二百零九、测试失败日志

故意:

删除不存在用户

确认日志:

success=0
error_message 有值

注意:

不要存异常堆栈到业务表

详细堆栈:

写应用日志

二百一十、测试密码日志安全

确认:

console

operation_log

login_log

都没有:

明文 password

二百一十一、测试 Token 日志安全

不要:

完整打印 Authorization

二百一十二、测试事务回滚

用户分配角色:

delete old
↓
模拟 batch insert 抛异常

确认:

旧角色关系仍然存在

二百一十三、测试删除角色事务

role_permission 删除成功
↓
user_role 删除成功
↓
role 删除失败

事务应:

全部回滚

二百一十四、常见错误:参数校验没生效

检查:

starter-validation 是否引入

@Valid

@Validated

字段注解是不是 jakarta.validation

二百一十五、常见错误:GlobalExceptionHandler 不生效

检查:

@RestControllerAdvice

类是否在 Spring 扫描包下

@ExceptionHandler 类型是否匹配

二百一十六、常见错误:校验异常返回默认 Spring JSON

说明:

没有正确处理 MethodArgumentNotValidException

二百一十七、常见错误:JWT secret 太短

某些算法要求:

足够强的密钥

不要:

123456

生产环境使用:

高强度随机 Secret

二百一十八、常见错误:JWT 过期时间单位写错

例如:

想设置 2 小时
结果写成 2 毫秒

建议配置名明确:

expire-minutes

二百一十九、常见错误:JWT 过期时间永远不过期

检查:

exp claim

是否真的设置。


二百二十、常见错误:缓存权限不更新

检查是否在:

assignRoles

assignPermissions

disableUser

disableRole

deleteUser

后清理。


二百二十一、常见错误:AOP 不执行

检查:

@Aspect

@Component

切点表达式

方法是否由 Spring Bean 调用

二百二十二、常见错误:AOP 自调用

和事务类似:

this.method()

可能绕过代理。


二百二十三、常见错误:AOP 吞异常

必须:

记录后继续 throw

二百二十四、常见错误:日志 AOP 记录所有参数

可能泄露:

password

token

一定要:

过滤敏感参数

二百二十五、常见错误:日志表无限增长

真实项目需要:

归档

定期清理

冷热分离

学习阶段:

了解即可

二百二十六、常见错误:缓存对象可变

如果本地缓存放:

可变 LoginUser

业务代码修改它:

可能污染缓存

最好:

只读使用

或者:

防御性复制

二百二十七、线程安全

ConcurrentHashMap:

Map 本身线程安全

但:

Value 内部对象

如果被并发修改:

仍可能有问题

二百二十八、Spring Service 默认单例

所以 Service 不要成员变量保存:

当前用户

当前请求 DTO

当前 Token

二百二十九、当前请求信息放哪里

可以:

request attribute

ThreadLocal

SecurityContext

学习阶段:

request attribute

足够。


二百三十、ThreadLocal 简单预览

可以让:

同一线程

访问当前用户。

但一定要:

finally remove

否则线程池环境可能:

数据泄漏到下一请求

二百三十一、为什么暂时不推荐自己写 ThreadLocal

容易忘:

remove

学习项目先:

request attribute

更安全直观。


二百三十二、Spring Security Context

以后 Spring Security 会提供:

SecurityContextHolder

管理当前认证信息。

这就是成熟框架替代:

我们手写 LoginUser 上下文

的方案。


二百三十三、RBAC 三阶段关系

第一阶段:

数据模型
+
CRUD
+
用户角色权限关系

第二阶段:

登录
+
Token
+
Interceptor
+
权限注解
+
菜单树

第三阶段:

统一异常
+
Validation
+
JWT 封装
+
Cache
+
Operation Log
+
AOP
+
安全完善

二百三十四、为什么三阶段之后才学事务 / AOP

现在你已经实际看到:

@Transactional

OperationLogAspect

权限切面

代理

自调用失效

再学习:

事务管理 / AOP

就不会只背概念。


二百三十五、RBAC 三阶段最终架构

Vue / Postman
↓
HTTP
↓
SpringBoot
↓
AuthInterceptor
│  ├─ TokenUtil
│  ├─ LoginUser Cache
│  └─ Permission Check
↓
Controller
↓
Validation
↓
Service
│  ├─ Business Rule
│  ├─ Transaction
│  └─ Cache Invalidation
↓
Mapper
↓
MyBatis
↓
MySQL
↓
OperationLogAspect
↓
Result<T>
↓
HTTP Response

二百三十六、最终数据库表

核心:

sys_user

sys_role

sys_permission

sys_user_role

sys_role_permission

扩展:

sys_operation_log

sys_login_log

二百三十七、最终核心接口

POST /api/auth/login

GET /api/auth/me

GET /api/users

POST /api/users

PUT /api/users/{id}

DELETE /api/users/{id}

PUT /api/users/{id}/roles

GET /api/roles

POST /api/roles

PUT /api/roles/{id}

DELETE /api/roles/{id}

PUT /api/roles/{id}/permissions

GET /api/permissions/tree

二百三十八、最终核心权限码

user:list

user:add

user:update

user:delete

user:assign-role

role:list

role:add

role:update

role:delete

role:assign-permission

permission:list

log:operation:list

log:login:list

二百三十九、最终安全清单

密码使用 BCrypt

不返回 password

不记录明文密码

Token 不放敏感信息

Token 有过期时间

JWT Secret 不写死生产值

未登录返回 401

无权限返回 403

权限必须后端校验

排序字段白名单

SQL 使用 #{}

参数使用 Validation

错误不暴露堆栈

日志脱敏

用户状态实时检查

权限变更清缓存

二百四十、最终事务清单

必须认真考虑:

创建用户 + 默认角色

删除用户 + 关系表

分配用户角色

删除角色 + 关系表

分配角色权限

二百四十一、最终性能清单

用户名唯一索引

角色 code 唯一索引

权限 code 唯一索引

user_role role_id 索引

role_permission permission_id 索引

分页

避免 SELECT *

避免 N+1

权限缓存

二百四十二、最终代码质量清单

Controller 薄

Service 负责业务

Mapper 负责数据库

DTO/VO 分离

统一异常

统一响应

统一校验

构造器注入

日志框架

不要 System.out.println

事务边界清晰

二百四十三、最终测试清单

认证:

登录成功

账号不存在

密码错误

账号禁用

Token 缺失

Token 错误

Token 过期

授权:

有权限

无权限

角色禁用

权限变更

角色变更

CRUD:

分页

新增

重复用户名

修改

删除

关系:

用户分配角色

角色分配权限

非法 roleId

非法 permissionId

事务:

中途异常回滚

日志:

成功操作

失败操作

敏感字段不泄露

缓存:

命中

未命中

角色变化失效

权限变化失效

用户禁用失效

二百四十四、面试问题 1

问:

RBAC 是什么?

答:

基于角色的访问控制。

用户通常不直接绑定大量权限,
而是用户绑定角色,
角色绑定权限,
用户通过角色获得权限。

二百四十五、面试问题 2

问:

401 和 403 区别?

答:

401 表示未认证或认证失效。

403 表示已经知道当前用户是谁,
但当前用户没有目标权限。

二百四十六、面试问题 3

问:

为什么 JWT 不建议直接存全部权限?

答:

权限多会导致 Token 变大,
而且角色或权限修改以后,
旧 Token 里的权限无法实时更新。

常见方案是 Token 只保存 userId,
权限放数据库或 Redis 缓存。

二百四十七、面试问题 4

问:

用户权限怎么查询?

答:

通过 user_role 找到用户角色,
再通过 role_permission 找到角色权限,
最后查询 permission。

多个角色可能拥有相同权限,
通常通过 DISTINCT 或 Set 去重。

二百四十八、面试问题 5

问:

为什么分配角色要事务?

答:

通常需要先删除用户旧角色,
再插入新角色。

如果删除成功但插入失败,
没有事务就会造成角色关系丢失。

因此需要保证整体提交或整体回滚。

二百四十九、面试问题 6

问:

为什么权限缓存需要失效?

答:

缓存的是用户当前角色和权限。

当用户角色、角色权限、用户状态等发生改变时,
缓存中的权限可能已经过期。

如果不清缓存,
用户会继续使用旧权限。

二百五十、面试问题 7

问:

为什么操作日志适合 AOP?

答:

新增、修改、删除等很多接口都需要记录
当前用户、请求路径、操作类型、耗时和结果。

这些属于横切逻辑,
不应该重复写在每个业务方法中。

AOP 可以统一拦截带日志注解的方法。

二百五十一、必须掌握的注解

@RestControllerAdvice

@ExceptionHandler

@Valid

@Validated

@NotBlank

@NotNull

@Size

@Min

@Max

@Transactional

@Aspect

@Around

二百五十二、必须掌握的安全概念

Authentication

Authorization

JWT

Bearer

BCrypt

401

403

水平越权

垂直越权

权限缓存

日志脱敏

二百五十三、必须掌握的 AOP 基础

Aspect

Join Point

Pointcut

Advice

Around

ProceedingJoinPoint

Proxy

本章先在:

操作日志

中认识。

下一章会系统讲。


二百五十四、必须回答的问题

学完应该能回答:

1. 为什么需要统一 Result?

2. 为什么需要 ErrorCode?

3. BusinessException 为什么常继承 RuntimeException?

4. @RestControllerAdvice 是做什么的?

5. @ExceptionHandler 是做什么的?

6. @Valid 是做什么的?

7. @NotBlank 和 @NotNull 有什么区别?

8. DTO 为什么推荐按场景分开?

9. JWT Secret 为什么不能写死生产值?

10. JWT Payload 为什么不能放密码?

11. Token 只放 userId 有什么好处?

12. 什么是 Cache Aside?

13. LoginUser 缓存什么时候失效?

14. 角色权限变化会影响哪些用户?

15. 为什么本地 Map 缓存不适合多实例?

16. 为什么 Redis 更适合共享权限缓存?

17. 操作日志应该记录哪些字段?

18. 为什么操作日志不能记录密码?

19. @OperationLog 有什么用?

20. 为什么日志适合 AOP?

21. joinPoint.proceed() 是做什么的?

22. AOP 为什么不能吞业务异常?

23. Interceptor 和 AOP 应该如何分工?

24. 功能权限和数据权限有什么区别?

25. 什么是水平越权?

26. 什么是垂直越权?

27. 为什么 Controller 默认单例要注意线程安全?

28. 为什么 ThreadLocal 用完要 remove?

29. 为什么权限变化后旧 JWT 可能存在实时性问题?

30. RBAC 三个阶段分别完成了什么?

二百五十五、RBAC 三阶段最终知识树

RBAC
│
├─ 数据层
│  ├─ User
│  ├─ Role
│  ├─ Permission
│  ├─ user_role
│  └─ role_permission
│
├─ 认证
│  ├─ Login
│  ├─ BCrypt
│  ├─ Token
│  ├─ JWT
│  └─ LoginUser
│
├─ 授权
│  ├─ permissionCode
│  ├─ RequirePermission
│  ├─ Interceptor
│  ├─ 401
│  └─ 403
│
├─ 权限展示
│  ├─ MENU
│  ├─ BUTTON
│  ├─ API
│  └─ Permission Tree
│
├─ 工程规范
│  ├─ DTO
│  ├─ VO
│  ├─ Result
│  ├─ ErrorCode
│  ├─ Validation
│  └─ Exception Handler
│
├─ 性能
│  ├─ Index
│  ├─ Batch
│  ├─ Avoid N+1
│  └─ Permission Cache
│
├─ 安全
│  ├─ Password Hash
│  ├─ Token Expire
│  ├─ Backend Permission
│  ├─ Horizontal Access
│  └─ Sensitive Log
│
└─ 可观测性
   ├─ Operation Log
   ├─ Login Log
   ├─ AOP
   └─ Slow Request

二百五十六、本章总结

RBAC 第三阶段的目标不是继续增加更多业务页面,

而是让整个权限系统:

更规范

更安全

更容易维护

更接近企业项目

统一响应:

Result<T>
+
ErrorCode

统一异常:

BusinessException
+
@RestControllerAdvice
+
@ExceptionHandler

统一参数校验:

@Valid
+
Validation Constraints

JWT:

只负责身份凭证

Secret 不硬编码生产值

Payload 不放敏感信息

必须有过期时间

权限缓存:

Token
↓
userId
↓
LoginUser Cache
↓
Permission Set

权限发生变化:

必须失效缓存

操作日志:

@OperationLog
+
AOP

统一记录:

谁

做了什么

是否成功

耗时多久

同时必须:

脱敏

不能记录密码

不能完整记录 Token

RBAC 三阶段到这里已经完整:

第一阶段
数据库和 CRUD

第二阶段
登录、Token、权限控制

第三阶段
异常、校验、缓存、日志、AOP 和工程化

这三个阶段已经把前面学习过的:

JavaBean

反射

注解

MyBatis

动态 SQL

JOIN

事务

索引

SpringBoot

RESTful

Validation

Interceptor

AOP

真正串成了一个综合项目。

按照课程表,下一篇正式进入:

事务管理 / AOP 思想

下一章会系统讲清:

Spring 事务

@Transactional

事务传播

事务隔离

事务失效

Spring AOP

动态代理

切点

通知

切面

代理对象