fix(exception): SSE 场景返回无 body 响应, 避免 HttpMessageNotWritableException 二次异常

This commit is contained in:
DaxPay Dev
2026-06-26 10:18:01 +08:00
parent 36f1ec9b09
commit b8e7fa9567

View File

@@ -12,6 +12,7 @@ import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.capability.auth.entity.TwoFactorChallengeResult;
import cn.daxpay.open.platform.capability.auth.exception.TwoFactorRequiredException;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.ValidationException;
@@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.validation.BindException;
@@ -32,6 +34,12 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
/// # 项目异常处理
///
/// ## SSE 异步端点说明
/// SSE 事件流端点(如 `/sse/connect`)基于 `SseEmitter`, 在客户端断连/超时/发送失败时,
/// Servlet 容器会做 ASYNC 重派发并抛出非 `SaTokenException` 的 `Throwable`(常为 `ClientAbortException`),
/// 最终落入本兜底处理. 此时响应 `Content-Type` 已锁为 `text/event-stream`, 无 JSON 转换器,
/// 写 body 会抛 `HttpMessageNotWritableException`. 故所有 handler 在 SSE 场景统一返回无 body 的 200 响应,
/// 并降级为 info 日志(多为客户端主动断连, 属常态).
@Slf4j
@RestControllerAdvice
@EnableConfigurationProperties(PlatformCommonProperties.class)
@@ -40,6 +48,17 @@ public class RestExceptionHandler {
private final PlatformCommonProperties properties;
/// 判断当前响应是否为 SSE 事件流(Content-Type 已锁定为 text/event-stream)
private boolean isSseStream(HttpServletResponse response) {
String contentType = response.getContentType();
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
}
/// SSE 场景统一日志: 降级为 info, 仅记录异常类型与消息, 不打印完整堆栈
private void logSse(Throwable ex) {
log.info("SSE 端点异常(通常为客户端断连), 类型={}, 消息={}", ex.getClass().getSimpleName(), ex.getMessage());
}
/// 获取异常消息,支持国际化
private String getMessage(BizException ex) {
String messageKey = ex.getMessageKey();
@@ -55,7 +74,11 @@ public class RestExceptionHandler {
/// 普通业务异常, 不需要进行堆栈跟踪
@ExceptionHandler(BizInfoException.class)
public Result<Void> handleBizInfoException(BizInfoException ex) {
public Object handleBizInfoException(BizInfoException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage());
String message = getMessage(ex);
return Res.response(ex.getCode(), message, MDC.get(CommonCode.TRACE_ID));
@@ -63,7 +86,11 @@ public class RestExceptionHandler {
/// 双因素认证挑战: 密码通过但需二次验证, 返回预认证令牌, 不计入登录失败
@ExceptionHandler(TwoFactorRequiredException.class)
public Result<TwoFactorChallengeResult> handleTwoFactorRequiredException(TwoFactorRequiredException ex) {
public Object handleTwoFactorRequiredException(TwoFactorRequiredException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage());
String message = getMessage(ex);
return Res.response(TwoFactorRequiredException.CODE, message, new TwoFactorChallengeResult(ex.getPreAuthToken()));
@@ -71,7 +98,11 @@ public class RestExceptionHandler {
/// 警告业务异常, 如果量多需要关注
@ExceptionHandler(BizWarnException.class)
public Result<Void> handleBizWarnException(BizWarnException ex) {
public Object handleBizWarnException(BizWarnException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.warn(ex.getMessage(), ex);
String message = getMessage(ex);
return Res.response(ex.getCode(), message, MDC.get(CommonCode.TRACE_ID));
@@ -79,7 +110,11 @@ public class RestExceptionHandler {
/// 致命警告业务异常, 需要进行立即进入排查
@ExceptionHandler(BizErrorException.class)
public Result<Void> handleBizErrorException(BizErrorException ex) {
public Object handleBizErrorException(BizErrorException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.error(ex.getMessage(), ex);
String message = getMessage(ex);
return Res.response(ex.getCode(), message, MDC.get(CommonCode.TRACE_ID));
@@ -87,7 +122,11 @@ public class RestExceptionHandler {
/// 业务异常
@ExceptionHandler(BizException.class)
public Result<Void> handleBusinessException(BizException ex) {
public Object handleBusinessException(BizException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
String message = getMessage(ex);
return Res.response(ex.getCode(), message, MDC.get(CommonCode.TRACE_ID));
@@ -95,7 +134,11 @@ public class RestExceptionHandler {
/// 请求参数校验未通过
@ExceptionHandler({ ConstraintViolationException.class })
public Result<Void> handleBusinessException(ConstraintViolationException ex) {
public Object handleBusinessException(ConstraintViolationException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
StringBuilder message = new StringBuilder();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
@@ -106,7 +149,11 @@ public class RestExceptionHandler {
/// 请求参数校验未通过
@ExceptionHandler({ MethodArgumentNotValidException.class })
public Result<Void> handleBusinessException(MethodArgumentNotValidException ex) {
public Object handleBusinessException(MethodArgumentNotValidException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
StringBuilder message = new StringBuilder();
for (var violation : ex.getAllErrors()) {
@@ -117,22 +164,34 @@ public class RestExceptionHandler {
/// 不支持 HTTP 请求方法异常
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public Result<?> HttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
public Object HttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
String message = I18nUtil.get("error.common.unsupportedOperate");
log.info(message, e);
log.info(message, ex);
return Res.error(message);
}
/// 请求参数校验未通过
@ExceptionHandler({ ValidationException.class })
public Result<Void> handleBusinessException(ValidationException ex) {
public Object handleBusinessException(ValidationException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
return Res.response(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, ex.getMessage(), MDC.get(CommonCode.TRACE_ID));
}
/// 页面或资源不存在
@ExceptionHandler({ NoResourceFoundException.class })
public ResponseEntity<Result<Void>> handleBusinessException(NoResourceFoundException ex) {
public ResponseEntity<?> handleBusinessException(NoResourceFoundException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
String message = I18nUtil.get("error.common.notFound");
Result<Void> result = Res.response(CommonErrorCode.SOURCES_NOT_EXIST, message, MDC.get(CommonCode.TRACE_ID));
@@ -141,7 +200,11 @@ public class RestExceptionHandler {
/// 处理 MissingServletRequestParameterException ( 缺少 必填的request param )
@ExceptionHandler(MissingServletRequestParameterException.class)
public Result<Void> handleMissingServletRequestParameterException(MissingServletRequestParameterException ex) {
public Object handleMissingServletRequestParameterException(MissingServletRequestParameterException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
String message = I18nUtil.get("error.common.parseParams");
return Res.response(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, message, MDC.get(CommonCode.TRACE_ID));
@@ -149,20 +212,33 @@ public class RestExceptionHandler {
/// 处理 HttpMessageConversionException
@ExceptionHandler(HttpMessageConversionException.class)
public Result<Void> handleHttpMessageConversionException(HttpMessageConversionException ex) {
public Object handleHttpMessageConversionException(HttpMessageConversionException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info(ex.getMessage(), ex);
return Res.response(CommonErrorCode.PARSE_PARAMETERS_ERROR, ex.getMessage(), MDC.get(CommonCode.TRACE_ID));
}
/// 处理 HttpMessageConversionException
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException ex) {
public Object handleBindException(BindException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.info("参数绑定失败 ", ex);
return Res.response(CommonErrorCode.PARSE_PARAMETERS_ERROR, ex.getMessage(), MDC.get(CommonCode.TRACE_ID));
}
/// 空指针异常
@ExceptionHandler(NullPointerException.class)
public Result<Void> handleNullPointerException(NullPointerException ex) {
public Object handleNullPointerException(NullPointerException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.warn("空指针 ", ex);
String message = I18nUtil.get("error.common.system");
return Res.response(CommonErrorCode.SYSTEM_ERROR, message, MDC.get(CommonCode.TRACE_ID));
@@ -170,7 +246,11 @@ public class RestExceptionHandler {
/// 处理运行时异常
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException ex) {
public Object handleRuntimeException(RuntimeException ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.error(ex.getMessage(), ex);
String message = I18nUtil.get("error.common.system");
if (properties.getException().isShowFullMessage()) {
@@ -182,7 +262,11 @@ public class RestExceptionHandler {
/// 处理 OutOfMemoryError
@ExceptionHandler(OutOfMemoryError.class)
public Result<Void> handleOomException(OutOfMemoryError ex) {
public Object handleOomException(OutOfMemoryError ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
log.error("内存不足错误 {}", ex.getMessage(), ex);
String message = I18nUtil.get("error.common.system");
return Res.response(CommonErrorCode.SYSTEM_ERROR, message, MDC.get(CommonCode.TRACE_ID));
@@ -190,7 +274,11 @@ public class RestExceptionHandler {
/// 处理 Throwable 异常
@ExceptionHandler(Throwable.class)
public Result<Void> handleThrowable(Throwable ex) {
public Object handleThrowable(Throwable ex, HttpServletResponse response) {
if (isSseStream(response)) {
logSse(ex);
return ResponseEntity.ok().build();
}
String message = I18nUtil.get("error.common.system");
if (properties.getException().isShowFullMessage()){
return Res.response(CommonErrorCode.SYSTEM_ERROR, ex.getMessage(), MDC.get(CommonCode.TRACE_ID));