解决Sa-Token在 Spring MVC + WebFlux 混合架构中流式接口报错SaTokenContext 上下文尚未初始化的问题

文章目录

    • 背景
    • [一、 问题复现](#一、 问题复现)
    • [二、 深度排查:为什么会报错?](#二、 深度排查:为什么会报错?)
      • [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-webspring-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)

二、 深度排查:为什么会报错?

通过分析堆栈日志,我们发现了几个关键线索:AsyncContextImplAsyncRunnable。这说明问题发生在 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 -> 拦截器放行。

这就好比机场安检:

  1. REQUEST 阶段(安检口):必须检查证件。
  2. ASYNC 阶段(候机厅内转机):你已经在安全区内了,不需要再查一次证件。

五、 相关原理知识

可以阅读我写的下面这篇文章:


六、 总结

在 Spring MVC + WebFlux 混合架构中,Sa-Token 的 ThreadLocal 模式确实会遇到挑战。通过理解 Servlet 3.1 的异步分发机制,我们可以精准地通过 DispatcherType 判断来规避此类问题。

最佳实践建议:

  1. 入口处取值 :在 Controller 方法第一行 Object loginId = StpUtil.getLoginId();
  2. 全链路传参 :将 loginId 显式传给 Service 层,严禁在 Flux 流内部(异步线程)调用 StpUtil
  3. 拦截器适配 :务必在拦截器中排除 DispatcherType.ASYNC,这是混合架构下的标准解法。

希望这篇博客能帮助遇到同样问题的朋友少走弯路!

相关推荐
QWQ___qwq2 小时前
Spring Security + MyBatis-Plus 实现自定义数据库用户认证
数据库·spring·mybatis
de_wizard2 小时前
【mybatis】基本操作:详解Spring通过注解和XML的方式来操作mybatis
xml·spring·mybatis
wuchen10042 小时前
网狐的定时器引擎架构理解
架构·定时器·网狐
@PHARAOH2 小时前
HOW - Moleculer 微服务构建分布式服务系统
微服务·云原生·架构
6+h2 小时前
【Spring】AOP核心之原始对象与代理对象
java·python·spring
KKKlucifer2 小时前
零信任架构下的安全服务:动态防御与持续合规双驱动
安全·架构
偷吃的耗子2 小时前
大数据报表系统技术方案与业务方案设计
大数据·架构
Java基基3 小时前
Spring让Java慢了30倍,JIT、AOT等让Java比Python快13倍,比C慢17%
java·开发语言·后端·spring
future02103 小时前
Spring AOP核心机制:代理与拦截揭秘
java·开发语言·spring·面试·aop