文章目录
-
- 背景
- [一、 问题复现](#一、 问题复现)
- [二、 深度排查:为什么会报错?](#二、 深度排查:为什么会报错?)
-
- [1. 执行流程分析](#1. 执行流程分析)
- [2. 根本原因](#2. 根本原因)
- [三、 解决方案](#三、 解决方案)
- [四、 安全性思考:这样做会有漏洞吗?](#四、 安全性思考:这样做会有漏洞吗?)
-
- [1. ASYNC 无法伪造](#1. ASYNC 无法伪造)
- [2. 逻辑闭环](#2. 逻辑闭环)
- [五、 相关原理知识](#五、 相关原理知识)
- [六、 总结](#六、 总结)
背景
随着 AI 应用的爆发,流式接口在业务开发中变得越来越普遍。在 Spring Boot 3.x 的大背景下,很多项目采用了 Spring MVC + WebFlux 的混合架构:既保留了 Servlet 生态的成熟稳定,又利用了 WebFlux 的响应式编程能力来处理并发与流式传输。
最近在SpringBoot 3.x项目中引入 Sa-Token(v1.44.0)进行权限认证时,遇到了一个棘手的问题:普通的同步接口一切正常,但在处理 SSE(Server-Sent Events)流式接口时,控制台疯狂报错:
cn.dev33.satoken.exception.SaTokenContextException: SaTokenContext 上下文尚未初始化
本文将深度复盘该问题的成因及解决方案。
一、 问题复现
项目依赖中同时存在 spring-boot-starter-web 和 spring-boot-starter-webflux。按照 Sa-Token 的官方文档,我们配置了标准的 Spring MVC 拦截器:
java
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/auth/login");
}
}
业务接口使用了 Flux 实现流式输出:
java
@PostMapping(value = "/send", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<CommonResult<XXXRespVO>> send(@RequestBody @Validated XXXReqVO reqVO) {
return xxxService.xxxSend(reqVO);
}
报错堆栈关键信息如下:
text
cn.dev33.satoken.exception.SaTokenContextException: SaTokenContext 上下文尚未初始化
at cn.dev33.satoken.context.SaTokenContextForThreadLocalStaff.getModelBox(...)
...
at cn.dev33.satoken.interceptor.SaInterceptor.preHandle(SaInterceptor.java:102)
...
at org.apache.catalina.core.AsyncContextImpl$AsyncRunnable.run(AsyncContextImpl.java:599)
二、 深度排查:为什么会报错?
通过分析堆栈日志,我们发现了几个关键线索:AsyncContextImpl 和 AsyncRunnable。这说明问题发生在 Servlet 的异步处理阶段。
1. 执行流程分析
当 Controller 返回 Flux 对象时,Spring MVC 的处理流程并非"一气呵成",而是分为了两个阶段:
-
第一阶段(REQUEST 阶段):
- Tomcat 主线程接收请求。
- 进入
SaInterceptor,此时处于同步线程,ThreadLocal 正常,鉴权成功。 - Controller 方法执行,返回
Flux对象。 - Spring MVC 判定返回值为响应式类型,调用
request.startAsync()开启异步模式,主线程结束并释放。
-
第二阶段(ASYNC 阶段):
- 当
Flux流开始发射数据时,Servlet 容器会触发一次 异步分发。 - 关键点:Spring MVC 的拦截器机制在异步分发时会再次执行。
- 此时,Tomcat 使用的是异步线程(或复用线程),该线程中没有 Sa-Token 的 ThreadLocal 上下文。
SaInterceptor再次执行preHandle,尝试获取上下文,结果为空,抛出异常。
- 当
2. 根本原因
Sa-Token 默认基于 ThreadLocal 存储上下文。在 Servlet 规范中,异步分发会导致线程切换或上下文重置,导致 ThreadLocal 丢失。而拦截器默认对 所有分发类型(包括 ASYNC)都进行拦截,从而引发了误报。
三、 解决方案
既然异步分发阶段只是数据的"搬运工",且鉴权已经在第一次 REQUEST 阶段完成,那么我们只需要让拦截器在异步分发时"放行"即可。
修改 SaTokenConfigure 配置类:
java
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 【核心修复】如果是异步分发(ASYNC),直接放行
// 异步分发阶段线程上下文已丢失,但鉴权已在 REQUEST 阶段完成
if (request.getDispatcherType() == DispatcherType.ASYNC) {
return true;
}
// 以下为正常鉴权逻辑,仅在 REQUEST 阶段执行
SaRouter.match("/**")
.notMatch("/auth/login", "/error", "/swagger-ui/**", "/v3/api-docs/**")
.check(r -> StpUtil.checkLogin());
return true;
}
}).addPathPatterns("/**");
}
}
代码解析:
通过判断 request.getDispatcherType(),我们识别出当前是异步分发,直接返回 true 放行。这样就避免了在 ASYNC 阶段调用 StpUtil 导致的上下文异常。
四、 安全性思考:这样做会有漏洞吗?
可能有同学会担心:"直接在 ASYNC 阶段放行,会不会导致未鉴权的请求绕过安全检查?"
答案是:绝对不会。
1. ASYNC 无法伪造
DispatcherType 是 Servlet 容器内部维护的状态标识,并非 HTTP 请求头或参数。
- REQUEST :凡是外部通过网络发起的请求,Tomcat 必定将其标记为
REQUEST。 - ASYNC :只有服务器内部代码调用了
request.startAsync()并触发分发时才会产生。
2. 逻辑闭环
- 黑客发起攻击 :请求被标记为
REQUEST-> 进入拦截器 -> 执行鉴权逻辑 -> 鉴权失败 -> 返回 401。流程结束,不可能触发 ASYNC。 - 正常用户请求 :请求被标记为
REQUEST-> 鉴权成功 -> Controller 返回 Flux -> 触发 ASYNC -> 拦截器放行。
这就好比机场安检:
- REQUEST 阶段(安检口):必须检查证件。
- ASYNC 阶段(候机厅内转机):你已经在安全区内了,不需要再查一次证件。
五、 相关原理知识
可以阅读我写的下面这篇文章:
六、 总结
在 Spring MVC + WebFlux 混合架构中,Sa-Token 的 ThreadLocal 模式确实会遇到挑战。通过理解 Servlet 3.1 的异步分发机制,我们可以精准地通过 DispatcherType 判断来规避此类问题。
最佳实践建议:
- 入口处取值 :在 Controller 方法第一行
Object loginId = StpUtil.getLoginId();。 - 全链路传参 :将
loginId显式传给 Service 层,严禁在Flux流内部(异步线程)调用StpUtil。 - 拦截器适配 :务必在拦截器中排除
DispatcherType.ASYNC,这是混合架构下的标准解法。
希望这篇博客能帮助遇到同样问题的朋友少走弯路!