fix(auth): 修复 SSE 异步连接 ASYNC 派发时 SaToken 上下文未初始化

SSE 端点(/notify/user/sse/connect)连接完成/超时/异常时, Servlet 容器做 ASYNC 重派发,
SaToken 上下文过滤器(OncePerRequestFilter, 仅 REQUEST 派发)不重新执行, 再次进入 SaInterceptor
抛 SaTokenContextException; 异常处理器对已锁定 text/event-stream 的响应写 JSON 又二次抛
HttpMessageNotWritableException.

- SaTokenConfigure: 包装 SaInterceptor, DispatcherType.ASYNC 时直接放行
- SaExceptionHandler: SaTokenException 命中 text/event-stream 时不写 body, 正常分支显式 JSON
This commit is contained in:
DaxPay Dev
2026-06-25 10:30:16 +08:00
parent c3e6af56dc
commit e0124221da
2 changed files with 31 additions and 4 deletions

View File

@@ -4,11 +4,15 @@ import cn.daxpay.open.platform.common.config.properties.PlatformStarterPropertie
import cn.daxpay.open.platform.capability.auth.handler.SaRouteHandler;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -58,7 +62,17 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.check(saRouteHandler.check(handler))
);
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(saInterceptor)
.addPathPatterns("/**");
// 包装一层: SSE 等异步连接(SseEmitter)在完成/超时/异常时, Servlet 容器会做 ASYNC 重派发,
// 此时 SaToken 上下文过滤器(OncePerRequestFilter, 仅 REQUEST 派发)不会重新执行, ThreadLocal 上下文为空,
// 若再次进入 SaInterceptor 会抛 SaTokenContextException. 鉴权已在初始 REQUEST 派发完成, ASYNC 派发直接放行.
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getDispatcherType() == DispatcherType.ASYNC) {
return true;
}
return saInterceptor.preHandle(request, response, handler);
}
}).addPathPatterns("/**");
}
}

View File

@@ -6,11 +6,13 @@ import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.capability.auth.exception.NotLoginException;
import cn.daxpay.open.platform.capability.auth.exception.RouterCheckException;
import cn.dev33.satoken.exception.SaTokenException;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -39,10 +41,21 @@ public class SaExceptionHandler {
}
/// sa鉴权业务异常
///
/// 注意: SSE 等异步端点产生的事件流响应 Content-Type 已锁定为 text/event-stream,
/// 无 JSON 消息转换器, 此时返回 Result 会抛 HttpMessageNotWritableException, 故仅返回无 body 的状态码.
@ExceptionHandler(SaTokenException.class)
public Result<Void> handleBusinessException(SaTokenException ex) {
public ResponseEntity<?> handleBusinessException(SaTokenException ex, HttpServletResponse response) {
log.info(ex.getMessage(), ex);
return Res.response(CommonCode.FAIL_CODE, ex.getMessage());
String contentType = response.getContentType();
// SSE 事件流响应: 无 JSON 转换器, 不写 body, 避免二次异常
if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
Result<Void> result = Res.response(CommonCode.FAIL_CODE, ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(result);
}
}