fix(I): HTTP 语义类异常返回真实 4xx 状态码, 不再兜底成 500

问题:GlobalExceptionHandler 只对业务异常/参数校验做了分类,其余异常一律被兜底的
      Exception handler 处理成 500「系统繁忙」。例如以 application/octet-stream 发送
      JSON 请求体本应返回 415 Unsupported Media Type,却得到 500,误导调用方与排障。

改动(iot-common,四个服务共用):
- ResultCode 新增:UNSUPPORTED_MEDIA_TYPE(415) / NOT_ACCEPTABLE(406) / PAYLOAD_TOO_LARGE(413)
- GlobalExceptionHandler 新增 HTTP 语义类异常处理:
    HttpMediaTypeNotSupportedException   -> 415
    HttpMediaTypeNotAcceptableException  -> 406
    HttpRequestMethodNotSupportedException-> 405
    MissingServletRequestParameterException / MethodArgumentTypeMismatchException
                                    / HttpMessageNotReadableException -> 400
    MaxUploadSizeExceededException       -> 413
- 响应约定:真实 HTTP 状态码 + body.code 与状态码保持一致(沿用 JwtAuthenticationEntryPoint 的
  401 约定);业务/参数异常维持 HTTP 200 + body.code 的历史约定,前端无需改动
- 这类异常属客户端错误,日志级别由 error 降为 warn,避免污染错误日志
- 新增单测 iot-common/src/test/java/com/iot/common/exception/GlobalExceptionHandlerHttpStatusTest.java
  (2 个用例全部通过)

生产验证:
  错误 Content-Type  -> HTTP 415  body.code=415
  错误请求方法       -> HTTP 405
  参数类型不匹配     -> HTTP 400
  请求体 JSON 非法   -> HTTP 400
  正常列表/物模型    -> HTTP 200
前端 axios 拦截器对非 401/403/500 的 4xx 走 data?.message 分支,提示更准确,无回归。
This commit is contained in:
2995840376 2026-10-27 19:31:56 +08:00
parent 0ff9103089
commit 4d8d38909d
3 changed files with 149 additions and 0 deletions

View File

@ -3,14 +3,29 @@ package com.iot.common.exception;
import com.iot.common.result.Result; import com.iot.common.result.Result;
import com.iot.common.result.ResultCode; import com.iot.common.result.ResultCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/** /**
* 全局异常处理器 * 全局异常处理器
*
* 响应约定:
* 业务/参数类异常 -> HTTP 200 + body.code 表达语义(沿用历史约定,前端按 body.code 判定)
* HTTP 语义类异常 -> 真实 HTTP 4xx 状态码 + body.code 与状态码一致
* (如 415 Unsupported Media Type,与 JwtAuthenticationEntryPoint 的 401 约定一致)
* 未预期异常 -> HTTP 200 + body.code 500
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@ -36,6 +51,85 @@ public class GlobalExceptionHandler {
return Result.failed(ResultCode.PARAM_ERROR.getCode(), message); return Result.failed(ResultCode.PARAM_ERROR.getCode(), message);
} }
// ===== HTTP 语义类异常:返回真实 4xx 状态码 =====
/**
* 请求的 Content-Type 不被支持(如以 application/octet-stream 发送 JSON 请求体)
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<Result<Void>> handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) {
String message = "不支持的请求媒体类型: " + e.getContentType();
log.warn("请求媒体类型不支持: {}", message);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(Result.failed(ResultCode.UNSUPPORTED_MEDIA_TYPE.getCode(), message));
}
/**
* 请求的 Accept 头与可返回的类型不匹配
*/
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<Result<Void>> handleHttpMediaTypeNotAcceptable(HttpMediaTypeNotAcceptableException e) {
String message = "无法满足请求的 Accept 类型";
log.warn("请求 Accept 类型不可接受: {}", message);
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE)
.body(Result.failed(ResultCode.NOT_ACCEPTABLE.getCode(), message));
}
/**
* 请求方法不被支持
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Result<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
String message = "不支持的请求方法: " + e.getMethod();
log.warn("请求方法不支持: {}", message);
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(Result.failed(ResultCode.METHOD_NOT_ALLOWED.getCode(), message));
}
/**
* 缺少必填请求参数
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Result<Void>> handleMissingParameter(MissingServletRequestParameterException e) {
String message = "缺少必填参数: " + e.getParameterName();
log.warn("缺少必填请求参数: {}", message);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.failed(ResultCode.PARAM_ERROR.getCode(), message));
}
/**
* 请求参数类型不匹配
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Result<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
String message = "参数类型不匹配: " + e.getName();
log.warn("请求参数类型不匹配: {}", message);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.failed(ResultCode.PARAM_ERROR.getCode(), message));
}
/**
* 请求体无法解析(JSON 格式错误等)
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Result<Void>> handleMessageNotReadable(HttpMessageNotReadableException e) {
String message = "请求体格式错误,无法解析";
log.warn("请求体无法解析: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.failed(ResultCode.PARAM_ERROR.getCode(), message));
}
/**
* 上传内容超过限制
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Result<Void>> handleMaxUploadSize(MaxUploadSizeExceededException e) {
String message = "上传内容超过大小限制";
log.warn("上传内容超限: {}", message);
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Result.failed(ResultCode.PAYLOAD_TOO_LARGE.getCode(), message));
}
/** /**
* 处理参数绑定异常 * 处理参数绑定异常
*/ */

View File

@ -43,6 +43,21 @@ public enum ResultCode {
*/ */
METHOD_NOT_ALLOWED(405, "方法不允许"), METHOD_NOT_ALLOWED(405, "方法不允许"),
/**
* 请求的媒体类型不被支持(Content-Type 不匹配)
*/
UNSUPPORTED_MEDIA_TYPE(415, "不支持的媒体类型"),
/**
* 无法接受(Accept 头与可返回类型不匹配)
*/
NOT_ACCEPTABLE(406, "无法满足请求的 Accept 类型"),
/**
* 请求体过大
*/
PAYLOAD_TOO_LARGE(413, "上传内容过大"),
/** /**
* 请求超时 * 请求超时
*/ */

View File

@ -0,0 +1,40 @@
package com.iot.common.exception;
import com.iot.common.result.Result;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* HTTP 语义类异常的全局处理器单测:
* 确认真实 HTTP 状态码与 body.code 保持一致(415 / 405 等),
* 不再被兜底的 Exception handler 统一当成 500「系统繁忙」。
*/
class GlobalExceptionHandlerHttpStatusTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void unsupportedMediaType_shouldReturn415() {
HttpMediaTypeNotSupportedException ex = new HttpMediaTypeNotSupportedException(
"application/octet-stream", List.of(MediaType.APPLICATION_JSON));
ResponseEntity<Result<Void>> resp = handler.handleHttpMediaTypeNotSupported(ex);
assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, resp.getStatusCode());
assertEquals(415, resp.getBody().getCode());
}
@Test
void methodNotSupported_shouldReturn405() {
HttpRequestMethodNotSupportedException ex = new HttpRequestMethodNotSupportedException("TRACE");
ResponseEntity<Result<Void>> resp = handler.handleMethodNotSupported(ex);
assertEquals(HttpStatus.METHOD_NOT_ALLOWED, resp.getStatusCode());
assertEquals(405, resp.getBody().getCode());
}
}