From 4d8d38909ddfa78b00a67313169aa56b6f3ca93d Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 19:31:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(I):=20HTTP=20=E8=AF=AD=E4=B9=89=E7=B1=BB?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E8=BF=94=E5=9B=9E=E7=9C=9F=E5=AE=9E=204xx=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=A0=81,=20=E4=B8=8D=E5=86=8D=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=88=90=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: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 分支,提示更准确,无回归。 --- .../exception/GlobalExceptionHandler.java | 94 +++++++++++++++++++ .../com/iot/common/result/ResultCode.java | 15 +++ .../GlobalExceptionHandlerHttpStatusTest.java | 40 ++++++++ 3 files changed, 149 insertions(+) create mode 100644 backend/iot-common/src/test/java/com/iot/common/exception/GlobalExceptionHandlerHttpStatusTest.java diff --git a/backend/iot-common/src/main/java/com/iot/common/exception/GlobalExceptionHandler.java b/backend/iot-common/src/main/java/com/iot/common/exception/GlobalExceptionHandler.java index b50f55c..0db6db7 100644 --- a/backend/iot-common/src/main/java/com/iot/common/exception/GlobalExceptionHandler.java +++ b/backend/iot-common/src/main/java/com/iot/common/exception/GlobalExceptionHandler.java @@ -3,14 +3,29 @@ package com.iot.common.exception; import com.iot.common.result.Result; import com.iot.common.result.ResultCode; 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.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.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; 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 @RestControllerAdvice @@ -36,6 +51,85 @@ public class GlobalExceptionHandler { return Result.failed(ResultCode.PARAM_ERROR.getCode(), message); } + // ===== HTTP 语义类异常:返回真实 4xx 状态码 ===== + + /** + * 请求的 Content-Type 不被支持(如以 application/octet-stream 发送 JSON 请求体) + */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> 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> 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> 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> 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> 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> 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> handleMaxUploadSize(MaxUploadSizeExceededException e) { + String message = "上传内容超过大小限制"; + log.warn("上传内容超限: {}", message); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(Result.failed(ResultCode.PAYLOAD_TOO_LARGE.getCode(), message)); + } + /** * 处理参数绑定异常 */ diff --git a/backend/iot-common/src/main/java/com/iot/common/result/ResultCode.java b/backend/iot-common/src/main/java/com/iot/common/result/ResultCode.java index 444955f..b38b36d 100644 --- a/backend/iot-common/src/main/java/com/iot/common/result/ResultCode.java +++ b/backend/iot-common/src/main/java/com/iot/common/result/ResultCode.java @@ -43,6 +43,21 @@ public enum ResultCode { */ METHOD_NOT_ALLOWED(405, "方法不允许"), + /** + * 请求的媒体类型不被支持(Content-Type 不匹配) + */ + UNSUPPORTED_MEDIA_TYPE(415, "不支持的媒体类型"), + + /** + * 无法接受(Accept 头与可返回类型不匹配) + */ + NOT_ACCEPTABLE(406, "无法满足请求的 Accept 类型"), + + /** + * 请求体过大 + */ + PAYLOAD_TOO_LARGE(413, "上传内容过大"), + /** * 请求超时 */ diff --git a/backend/iot-common/src/test/java/com/iot/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/backend/iot-common/src/test/java/com/iot/common/exception/GlobalExceptionHandlerHttpStatusTest.java new file mode 100644 index 0000000..20b2790 --- /dev/null +++ b/backend/iot-common/src/test/java/com/iot/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -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> 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> resp = handler.handleMethodNotSupported(ex); + assertEquals(HttpStatus.METHOD_NOT_ALLOWED, resp.getStatusCode()); + assertEquals(405, resp.getBody().getCode()); + } +}