Spring Boot脚手架集成Sa-Token实现生产级RBAC权限管理

文章目录

    • 前言:业务背景与痛点
    • 技术选型思考
    • 数据库设计 (RBAC 模型)
      • [1. 核心表结构说明](#1. 核心表结构说明)
    • [核心架构与数据流向(Mermaid 图解)](#核心架构与数据流向(Mermaid 图解))
    • 环境准备与前置边界
      • [1. 技术栈与版本说明](#1. 技术栈与版本说明)
      • [2. 技术局限性声明](#2. 技术局限性声明)
    • 核心实践与核心参数推演
      • [1. Maven 依赖配置](#1. Maven 依赖配置)
      • [2. 核心配置文件 (sa-token-config.yml)](#2. 核心配置文件 (sa-token-config.yml))
        • [📌 核心参数因果链推演:](#📌 核心参数因果链推演:)
      • [3. 权限及角色加载实现 (StpInterfaceImpl.java)](#3. 权限及角色加载实现 (StpInterfaceImpl.java))
      • [4. 线程上下文 SessionContext.java](#4. 线程上下文 SessionContext.java)
      • [5. 自定义上下文拦截器 (SaTokenContextInterceptor.java)](#5. 自定义上下文拦截器 (SaTokenContextInterceptor.java))
      • [6. 拦截器注册配置 (InterceptorConfig.java)](#6. 拦截器注册配置 (InterceptorConfig.java))
      • [7. 控制层鉴权实战 (UserController.java)](#7. 控制层鉴权实战 (UserController.java))
    • 运行效果与结果验证
      • [步骤 1:调用登录接口,获取 Token 凭证](#步骤 1:调用登录接口,获取 Token 凭证)
      • [步骤 2:携带 Token 请求受保护接口 `/user/info`](#步骤 2:携带 Token 请求受保护接口 /user/info)
      • [步骤 3:验证权限越权拦截](#步骤 3:验证权限越权拦截)
    • 生产踩坑与避坑指南
      • [🚨 事故 1:Tomcat 线程池复用引发的用户上下文串流与越权](#🚨 事故 1:Tomcat 线程池复用引发的用户上下文串流与越权)
      • [🚨 事故 2:Redis 序列化 ClassCastException 导致系统瘫痪](#🚨 事故 2:Redis 序列化 ClassCastException 导致系统瘫痪)
    • 总结

📌 项目源码与分支信息:

  • 项目源码地址GitHub - xf-boot-base
  • 本篇配套分支feature/admin-auth-satoken (通过命令 git checkout feature/admin-auth-satoken 切换)

前言:业务背景与痛点

在项目架构演进中,针对不同业务场景需要选择不同力度的会话控制方案。

本项目的主体功能基于 master 分支,该分支主要面向 C端(用户端)应用

针对 C端 庞大的用户体量与分布式扩展需求,master 分支采用了经典的 JWT(JSON Web Token) 进行无状态登录认证,服务端无需维持会话状态,轻量且高效。

然而,随着系统扩展出 B端(后台管理系统) ,我们面临的核心痛点转向了复杂的 RBAC(基于角色的访问控制) 以及强力会话管控(如账号实时封禁、强制下线、权限动态变更生效)。

若强行在 C端的 JWT 架构上堆叠这些功能,不仅会让原本无状态的 token 变得臃肿,还会丧失 JWT 本身的无状态优势。

为了解决 B端 的安全痛点,我们在 feature/admin-auth-satoken 分支中引入了国产轻量级安全框架 Sa-Token

它以极简 we API 设计和极低的侵入性,完美契合后台权限体系。

本文将结合本分支的真实代码,带你一步步落地一套高可用、生产级的权限系统。


技术选型思考

对于权限管理框架的选型,开发者常在 Spring SecurityApache ShiroSa-Token 之间进行权衡。

以下是针对这三款主流框架的客观对比:

维度 Spring Security Apache Shiro Sa-Token
上手难度 较高(过滤器链机制复杂,概念繁杂) 中等(配置略显传统) 极低(开箱即用,API 人性化)
社区活跃度 极高(Spring 官方维护,行业事实标准) 较低(近年来维护节奏放缓) 极高(国内活跃,文档建设完善)
分布式支持 优秀(可无缝贴合 Spring Cloud 生态) 一般(通常需要手动集成 Redis) 极佳(内置 Redis Jackson 等整合插件)
会话管控 相对繁琐(需要定制过滤器与监听器) 一般 极强(原生支持强制下线、踢人等)
适用场景 中大型企业级项目、高度定制的安全网关 遗留单体系统的日常维护与迭代 快速迭代、追求轻量级的中小型系统

💡 选型决策建议:

两个框架并没有绝对的优劣,只有更适合当前业务的选择。

如果项目追求高标准的安全认证、完善的生态集成(如 OAuth2),Spring Security 是行业首选(我们将在下一篇博客中进行深度集成拆解)。

如果项目以交付速度为先,且需要极高开发体验与灵活的会话管理,Sa-Token 则是极好的敏捷开发方案。

话不多说,我们首先来看本篇聚焦的 Sa-Token 的具体实践......


数据库设计 (RBAC 模型)

为了实现标准的 RBAC 权限控制,本分支设计并使用了以下五张核心表,用以支撑"用户 -> 角色 -> 权限"的经典多对多模型。

1. 核心表结构说明

  • sys_user:用户表。存储用户的基本信息(账号、密码、昵称等)。
  • sys_role:角色表。定义系统中的角色角色(如:超级管理员、普通用户)。
  • sys_permission :权限(菜单)表。定义具体的资源权限标识(如 user:info, user:add 等)。
  • sys_user_role:用户-角色关联表。多对多关联关系。
  • sys_role_permission:角色-权限关联表。多对多关联关系。

SQL 脚本说明 :本分支的完整 DDL 结构及测试初始化数据已包含在项目根目录的 sql 文件夹中。


核心架构与数据流向(Mermaid 图解)

在本项目中,我们设计了 双拦截器机制 ,在确保安全鉴权的同时,实现了 ThreadLocal 线程上下文的注入,以便于在后续业务链路中随时、优雅地获取当前登录用户信息。

以下是核心请求数据流向图:
#mermaid-svg-HxekmLEiI1YlfOCk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HxekmLEiI1YlfOCk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HxekmLEiI1YlfOCk .error-icon{fill:#552222;}#mermaid-svg-HxekmLEiI1YlfOCk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HxekmLEiI1YlfOCk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HxekmLEiI1YlfOCk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HxekmLEiI1YlfOCk .marker.cross{stroke:#333333;}#mermaid-svg-HxekmLEiI1YlfOCk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HxekmLEiI1YlfOCk p{margin:0;}#mermaid-svg-HxekmLEiI1YlfOCk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HxekmLEiI1YlfOCk .cluster-label text{fill:#333;}#mermaid-svg-HxekmLEiI1YlfOCk .cluster-label span{color:#333;}#mermaid-svg-HxekmLEiI1YlfOCk .cluster-label span p{background-color:transparent;}#mermaid-svg-HxekmLEiI1YlfOCk .label text,#mermaid-svg-HxekmLEiI1YlfOCk span{fill:#333;color:#333;}#mermaid-svg-HxekmLEiI1YlfOCk .node rect,#mermaid-svg-HxekmLEiI1YlfOCk .node circle,#mermaid-svg-HxekmLEiI1YlfOCk .node ellipse,#mermaid-svg-HxekmLEiI1YlfOCk .node polygon,#mermaid-svg-HxekmLEiI1YlfOCk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HxekmLEiI1YlfOCk .rough-node .label text,#mermaid-svg-HxekmLEiI1YlfOCk .node .label text,#mermaid-svg-HxekmLEiI1YlfOCk .image-shape .label,#mermaid-svg-HxekmLEiI1YlfOCk .icon-shape .label{text-anchor:middle;}#mermaid-svg-HxekmLEiI1YlfOCk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HxekmLEiI1YlfOCk .rough-node .label,#mermaid-svg-HxekmLEiI1YlfOCk .node .label,#mermaid-svg-HxekmLEiI1YlfOCk .image-shape .label,#mermaid-svg-HxekmLEiI1YlfOCk .icon-shape .label{text-align:center;}#mermaid-svg-HxekmLEiI1YlfOCk .node.clickable{cursor:pointer;}#mermaid-svg-HxekmLEiI1YlfOCk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HxekmLEiI1YlfOCk .arrowheadPath{fill:#333333;}#mermaid-svg-HxekmLEiI1YlfOCk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HxekmLEiI1YlfOCk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HxekmLEiI1YlfOCk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HxekmLEiI1YlfOCk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HxekmLEiI1YlfOCk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HxekmLEiI1YlfOCk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HxekmLEiI1YlfOCk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HxekmLEiI1YlfOCk .cluster text{fill:#333;}#mermaid-svg-HxekmLEiI1YlfOCk .cluster span{color:#333;}#mermaid-svg-HxekmLEiI1YlfOCk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HxekmLEiI1YlfOCk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HxekmLEiI1YlfOCk rect.text{fill:none;stroke-width:0;}#mermaid-svg-HxekmLEiI1YlfOCk .icon-shape,#mermaid-svg-HxekmLEiI1YlfOCk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HxekmLEiI1YlfOCk .icon-shape p,#mermaid-svg-HxekmLEiI1YlfOCk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HxekmLEiI1YlfOCk .icon-shape .label rect,#mermaid-svg-HxekmLEiI1YlfOCk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HxekmLEiI1YlfOCk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HxekmLEiI1YlfOCk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HxekmLEiI1YlfOCk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 携带 Authorization 头
未登录/无权限
通过
读取用户信息
AOP 注解校验
通过
前端请求
Spring MVC WebMvcConfigurer

  1. SaInterceptor 安检
    返回 401/403 异常
  2. SaTokenContextInterceptor 搬运
    从 Redis Session 获取 loginUser
    存入 ThreadLocal SessionContext
    Controller 层 业务逻辑
    SessionContext.getInstance().get()
    Sa-Token 权限/角色校验
    执行 Mapper 数据库操作
    请求结束 afterCompletion
    清理 ThreadLocal 释放内存
    响应前端

环境准备与前置边界

1. 技术栈与版本说明

在落地本方案时,请确保你的基础环境与以下版本保持一致或向上兼容:

  • JDK : 17
  • Spring Boot : 3.3.3
  • Sa-Token : 1.44.0
  • MySQL : 8.1.0
  • Redis : 5.0+ (生产环境强依赖 Redis 做分布式会话持久化)

2. 技术局限性声明

⚠️ 生产边界警告

本方案适用于**中等量级(日活十万级以内)**的系统。

在高并发场景下,Sa-Token 的 AOP 注解校验会频繁调用 StpInterface 获取权限列表。

如果直接查库会给数据库造成巨大压力,必须在 StpInterfaceImpl 中引入多级缓存(如 Caffeine + Redis)。


核心实践与核心参数推演

1. Maven 依赖配置

在项目 pom.xml 中,引入 Sa-Token 的 Spring Boot 3 整合包以及 Redis Jackson 序列化组件:

xml 复制代码
<properties>
        <commons-pool2.version>2.11.1</commons-pool2.version>
        <sa-token.version>1.44.0</sa-token.version>
    </properties>

<!-- Sa-Token 核心依赖 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
    <version>${sa-token.version}</version>
</dependency>

<!-- Sa-Token 整合 Redis(使用 Jackson 序列化,便于在 Redis 中直观查看) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>${sa-token.version}</version>
</dependency>

<!-- Redis 物理连接池依赖 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>${commons-pool2.version}</version>
</dependency>

2. 核心配置文件 (sa-token-config.yml)

完整的 sa-token-config.yml 配置如下:

yaml 复制代码
# Sa-Token 核心参数配置
sa-token:
  # Token 标识名(前端请求 Header 中需要携带的 Key)
  token-name: Authorization
  # Token 前缀(遵循 Bearer Token 标准规范,注意后面有一个空格)
  token-prefix: Bearer
  # Token 有效期(单位:秒)。2592000 秒即 30 天
  timeout: 2592000
  # 临时有效期(指定时间内无操作则半途失效,-1 代表不开启)
  activity-timeout: -1
  # 是否允许同一账号多端同时登录
  is-concurrent: true
  # 在多人登录同一账号时,是否共享同一个 Token
  is-share: true
  # Token 生成风格,采用 uuid 格式
  token-style: uuid
  # 是否向控制台打印框架内部日志
  is-log: false
  # 是否从 Cookie 中尝试读取 Token
  is-read-cookie: false
  # 是否从 Header 中读取 Token(前后端分离项目必须开启)
  is-read-header: true
  # 是否从 Body 请求体中读取 Token
  is-read-body: false
📌 核心参数因果链推演:
  • token-style: uuid
    • 因果推演 :相较于 jwt 的去中心化设计,uuid 风格下 Token 本身仅作为随机 Key。
    • 用户的登录状态与数据完全保留在 Redis 中。当需要实现强力踢人账号封禁实时更改权限时,只需在 Redis 中删除或修改对应 Key 即可,具有极高实时性与安全把控度。
  • token-prefix: Bearer
    • 因果推演 :标准的前后端分离开发中,使用 Bearer 前缀能够无缝对接各种标准的 API 网关及第三方客户端,规范接口通信协议。
  • timeout: 2592000activity-timeout: -1
    • 因果推演 :对于 B 端管理后台,将全局有效期设为 30 天,并不设置活跃超时,可减少用户频繁登录的烦恼。对于安全性极高的系统,可配置 activity-timeout: 1800(30分钟无操作自动注销)。

3. 权限及角色加载实现 (StpInterfaceImpl.java)

我们需要实现 StpInterface 接口,以便 Sa-Token 在进行权限校验时,能够动态加载当前登录用户的权限和角色。

完整的 StpInterfaceImpl.java 代码如下:

java 复制代码
package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.stp.StpInterface;
import cn.xf.basedemo.mappers.SysPermissionMapper;
import cn.xf.basedemo.mappers.SysRoleMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @Description: 权限加载组件类,告诉 Sa-Token 如何获取用户的角色 and 权限
 * @ClassName: StpInterfaceImpl
 * @Author: xiongfeng
 */
@Component
public class StpInterfaceImpl implements StpInterface {

    @Autowired
    private SysPermissionMapper sysPermissionMapper;

    @Autowired
    private SysRoleMapper sysRoleMapper;

    @Override
    public List<String> getPermissionList(Object userId, String loginType) {
        // 根据用户 ID 查数据库,获取该用户拥有的权限标识列表(如:["user:add", "user:delete"])
        Long uId = Long.valueOf(userId.toString());
        return sysPermissionMapper.getPermissionListByUserId(uId);
    }

    @Override
    public List<String> getRoleList(Object userId, String loginType) {
        // 根据用户 ID 查数据库,获取该用户拥有的角色编码列表(如:["super-admin", "common-user"])
        Long uId = Long.valueOf(userId.toString());
        return sysRoleMapper.getRoleListByUserId(uId);
    }
}

4. 线程上下文 SessionContext.java

用于在当前请求线程中临时保存用户信息,实现业务代码解耦。

完整的 SessionContext.java 代码如下:

java 复制代码
package cn.xf.basedemo.interceptor;

import cn.xf.basedemo.common.model.LoginUser;

/**
 * @program: xf-boot-base
 * @ClassName SessionContext
 * @description: 线程上下文,用于持有当前登录用户的信息
 * @author: xiongfeng
 **/
public class SessionContext {

    private ThreadLocal<LoginUser> threadLocal;

    private SessionContext() {
        this.threadLocal = new ThreadLocal<>();
    }

    /**
     * 使用静态内部类创建单例
     */
    private static class Context {
        private static final SessionContext INSTANCE = new SessionContext();
    }

    public static SessionContext getInstance() {
        return Context.INSTANCE;
    }

    public void set(LoginUser user) {
        this.threadLocal.set(user);
    }

    public LoginUser get() {
        return this.threadLocal.get();
    }

    public void clear() {
        this.threadLocal.remove();
    }
}

5. 自定义上下文拦截器 (SaTokenContextInterceptor.java)

该拦截器的职责是实现"搬运工"的角色:在 Sa-Token 验证登录通过后,取出缓存的用户详情并塞入 SessionContext,同时在请求结束时负责清理,规避内存泄漏。

完整的 SaTokenContextInterceptor.java 代码如下:

java 复制代码
package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.stp.StpUtil;
import cn.xf.basedemo.common.model.LoginUser;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * @Description: Sa-Token 上下文拦截器
 *               用于将 Sa-Token Session 中的用户信息注入到 SessionContext (ThreadLocal)
 *               以方便业务层随时获取登录用户信息 (SessionContext.getInstance().get())
 * @Author: xiongfeng
 */
@Component
public class SaTokenContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 如果已登录,尝试从 Session 中获取用户信息并注入 ThreadLocal
        if (StpUtil.isLogin()) {
            // 从 Sa-Token Session 中读取 loginUser (需确保登录时已存入)
            LoginUser loginUser = (LoginUser) StpUtil.getSession().get("loginUser");
            if (loginUser != null) {
                SessionContext.getInstance().set(loginUser);
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        // 请求结束后必须清理 ThreadLocal,防止 Tomcat 线程池复用导致内存泄漏和数据交叉污染
        SessionContext.getInstance().clear();
    }
}

6. 拦截器注册配置 (InterceptorConfig.java)

在这里将 Sa-Token 的核心路由拦截器与我们的上下文拦截器串联到 Spring MVC 链路中。

完整的 InterceptorConfig.java 代码如下:

java 复制代码
package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @program: spring-boot-base-demo
 * @ClassName InterceptorConfig
 * @description: 拦截器全局配置类
 * @author: xiongfeng
 **/
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

    @Autowired
    private SaTokenContextInterceptor saTokenContextInterceptor;

    // 统一配置排除路径,放行登录接口、静态资源及 Knife4j 接口文档
    private static final String[] EXCLUDE_PATHS = {
            "/user/login",
            "/web/**",
            "/swagger-resources/**",
            "/webjars/**",
            "/v3/**",
            "/doc.html",
            "/swagger-ui.html",
            "/swagger-ui/**"
    };

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 1. 注册 Sa-Token 拦截器 (负责拦截未登录请求并支持注解鉴权)
        registry.addInterceptor(new SaInterceptor(handler -> {
            cn.dev33.satoken.stp.StpUtil.checkLogin();
        }))
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);

        // 2. 注册 Context 拦截器 (负责注入 ThreadLocal,便于随时获取用户信息)
        registry.addInterceptor(saTokenContextInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);
    }

    /**
     * 放行 Knife4j 静态资源请求
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

7. 控制层鉴权实战 (UserController.java)

完整的 UserController.java 控制器代码,包含了登录入口以及丰富的鉴权注解示例:

java 复制代码
package cn.xf.basedemo.controller.business;

import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.annotation.SaMode;
import cn.xf.basedemo.common.model.LoginUser;
import cn.xf.basedemo.common.model.RetObj;
import cn.xf.basedemo.interceptor.SessionContext;
import cn.xf.basedemo.model.res.LoginInfoRes;
import cn.xf.basedemo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
 * @program: xf-boot-base
 * @ClassName UserController
 * @description: 用户控制器,包含登录与各种角色权限拦截演示
 * @author: xiongfeng
 **/
@RestController
@RequestMapping("/user")
@Tag(name = "用户控制器")
public class UserController {

    @Autowired
    private UserService userService;

    @Operation(summary = "用户登录", description = "用户登录接口,登录成功后会下发 Token")
    @PostMapping("/login")
    public RetObj login(@RequestBody @Validated LoginInfoRes res) {
        return userService.login(res);
    }

    @Operation(summary = "用户信息", description = "获取当前已登录用户的详细信息")
    @PostMapping("/info")
    @SaCheckPermission("user:info") // 必须拥有 user:info 权限才能访问
    public RetObj info() {
        // 从 ThreadLocal 上下文中直接获取用户信息,优雅且解耦
        LoginUser loginUser = SessionContext.getInstance().get();
        return RetObj.success(loginUser);
    }

    @Operation(summary = "注解示例-角色校验", description = "必须具有 'super-admin' 角色才能访问")
    @PostMapping("/check-role")
    @SaCheckRole("super-admin")
    public RetObj checkRole() {
        return RetObj.success("您拥有 super-admin 角色,验证通过");
    }

    @Operation(summary = "注解示例-权限组合(OR)", description = "只要拥有 user:add 或 user:update 其中一个权限即可")
    @PostMapping("/check-permission-or")
    @SaCheckPermission(value = { "user:add", "user:update" }, mode = SaMode.OR)
    public RetObj checkPermissionOr() {
        return RetObj.success("您拥有 user:add 或 user:update 权限,验证通过");
    }

    @Operation(summary = "注解示例-权限组合(AND)", description = "必须同时拥有 user:delete 和 user:export 权限")
    @PostMapping("/check-permission-and")
    @SaCheckPermission(value = { "user:delete", "user:export" }, mode = SaMode.AND)
    public RetObj checkPermissionAnd() {
        return RetObj.success("您同时拥有 user:delete 和 user:export 权限,验证通过");
    }

    @Operation(summary = "注解示例-忽略鉴权", description = "无需登录即可访问(常用于注册、验证码等公开接口)")
    @PostMapping("/public-api")
    @SaIgnore
    public RetObj publicApi() {
        return RetObj.success("这是一个公开接口,@SaIgnore 生效");
    }
}

运行效果与结果验证

为保证功能 100% 正常复现,请按照以下步骤进行联调测试。

步骤 1:调用登录接口,获取 Token 凭证

  • 请求类型POST
  • 请求路径/user/login
  • 请求体 :包含经过 RSA 算法加密的账号密码数据 LoginInfoRes
  • 预期响应
    返回 code: 200,并在返回结果中携带 token 值,例如:"token": "4a719c8f-a9eb-4a6c-9407-cb8b23f89e4c"

步骤 2:携带 Token 请求受保护接口 /user/info

  • 请求类型POST
  • 请求路径/user/info
  • 请求头 :必须添加 Authorization: Bearer 4a719c8f-a9eb-4a6c-9407-cb8b23f89e4c
  • 预期响应
    控制台及响应体正常返回该登录用户的昵称、账户等 JSON 信息。此时 SaTokenContextInterceptor 已成功将用户信息搬运到了 SessionContext 中。

步骤 3:验证权限越权拦截

  • 测试手段 :使用不具备 super-admin 角色的普通用户 Token,请求 /user/check-role
  • 预期响应
    接口被 SaInterceptor 拦截,系统抛出 NotRoleException 或触发全局异常处理器返回自定义错误消息,例如:"无此角色:super-admin"。

    拥有权限返回

生产踩坑与避坑指南

在生产环境中落地 Sa-Token 会面临更复杂的网络和高并发场景,以下整理了两个极高频的真实事故案例,请注意规避:

🚨 事故 1:Tomcat 线程池复用引发的用户上下文串流与越权

  • 问题现象
    在高并发情况下,A 用户访问系统时,接口中 SessionContext.getInstance().get() 竟然拿到了 B 用户的信息,导致严重的数据越权读取。

  • 原因分析
    Web 容器(如 Tomcat)底层采用线程池 来处理 HTTP 请求。
    SaTokenContextInterceptorpreHandle 方法中,我们将用户信息存入了当前线程 of ThreadLocal(即 SessionContext)里。
    如果请求在执行过程中抛出未捕获异常、或者由于某些原因没有调用 afterCompletion 进行清理,该线程被收回线程池并重新分配给另一个请求时,新请求就会读取到上一次未清除的残留数据。

  • ✅ 解决方案
    必须在拦截器的 afterCompletion 中,强制调用清理方法,哪怕请求抛出了异常,也必须清空。

    java 复制代码
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 彻底清空 ThreadLocal 变量,防患于未然
        SessionContext.getInstance().clear();
    }

🚨 事故 2:Redis 序列化 ClassCastException 导致系统瘫痪

  • 问题现象
    系统热部署或升级微服务版本后,原本正常运行的系统突然全线报错:java.lang.ClassCastException: cn.xf.basedemo.common.model.LoginUser cannot be cast to...,导致所有用户被迫下线且无法登录。
  • 原因分析
    由于引入了 sa-token-redis-jackson,Sa-Token 会把 LoginUser 实体通过 Jackson 序列化后存入 Redis Session 中。
    Jackson 默认会带入类的全路径名。如果应用重启、代码重构移动了类路径、或者包路径在多个子服务中不完全一致,Jackson 反序列化时就会因为类定义不匹配而报错。
  • ✅ 解决方案
    1. 首选方案 :避免将复杂的自定义实体对象直接存入 Sa-Token Session。可以将用户信息转化为 Map<String, Object> 或直接存入 JSON 格式的字符串。
    2. 备选方案 :如果必须存对象,务必在 LoginUser 中显式声明一个固定的 serialVersionUID,并且确保各个微服务模块中的包路径完全一致。

总结

通过 UUID + Redis 模式的 Sa-Token 架构,我们以极低的代码成本在 Spring Boot 3 中落地了功能强大且便于实时管控的 RBAC 权限体系。

同时,利用双拦截器,我们实现了安全鉴权与线程上下文(ThreadLocal)的无缝集成,保障了用户信息的全局随取随用。

相关推荐
雯宝1 小时前
|____2.1 FreeRTOS 深度解析--链表
系统架构
世界尽头与你1 小时前
Spring Boot Watcher 未授权访问漏洞
spring boot·安全·网络安全·渗透测试
韦胖漫谈IT1 小时前
选语言不是站队,是选适合问题的工具
java·python·ai·rust·go·技术落地
lpd_lt1 小时前
AI生成Spring Boot + Vue 3 + MySQL + MyBatis-Plus的项目实战
java·spring boot·vue·ai编程
JAVA面经实录9171 小时前
Kafka 全套学习知识手册
java·kafka
绝知此事1 小时前
RabbitMQ 从入门到精通:Spring Boot 实战三部曲(三)—— 高级应用与性能优化
spring boot·rabbitmq·java-rabbitmq
源图客1 小时前
【亚马逊 SP-API 实战】Java 批量创建变体 Listing(父商品 + 子变体 + 独立图片)完整教程(亲测可用)
java·大数据·python
茫忙然1 小时前
Claude Code 接入 DeepSeek 或 多模型 教程(Linux)
java·linux·数据库
绝知此事1 小时前
RabbitMQ 从入门到精通:Spring Boot 实战三部曲(一)—— 基础核心与快速上手
spring boot·rabbitmq·java-rabbitmq