文章目录
-
- 前言:业务背景与痛点
- 技术选型思考
- 数据库设计 (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 Security 、Apache Shiro 与 Sa-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
- SaInterceptor 安检
返回 401/403 异常 - 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: 2592000与activity-timeout: -1:- 因果推演 :对于 B 端管理后台,将全局有效期设为 30 天,并不设置活跃超时,可减少用户频繁登录的烦恼。对于安全性极高的系统,可配置
activity-timeout: 1800(30分钟无操作自动注销)。
- 因果推演 :对于 B 端管理后台,将全局有效期设为 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 请求。
在SaTokenContextInterceptor的preHandle方法中,我们将用户信息存入了当前线程 ofThreadLocal(即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 反序列化时就会因为类定义不匹配而报错。 - ✅ 解决方案 :
- 首选方案 :避免将复杂的自定义实体对象直接存入 Sa-Token Session。可以将用户信息转化为
Map<String, Object>或直接存入 JSON 格式的字符串。 - 备选方案 :如果必须存对象,务必在
LoginUser中显式声明一个固定的serialVersionUID,并且确保各个微服务模块中的包路径完全一致。
- 首选方案 :避免将复杂的自定义实体对象直接存入 Sa-Token Session。可以将用户信息转化为
总结
通过 UUID + Redis 模式的 Sa-Token 架构,我们以极低的代码成本在 Spring Boot 3 中落地了功能强大且便于实时管控的 RBAC 权限体系。
同时,利用双拦截器,我们实现了安全鉴权与线程上下文(ThreadLocal)的无缝集成,保障了用户信息的全局随取随用。