主题
Spring Boot 统一异常处理指南
本指南循序渐进介绍 Spring Boot 统一异常处理机制。从局部拦截到全局处理,再到自定义异常体系,每步只引入一个新概念,同一"在线书店"场景贯穿全文。
适用版本:Spring Boot 3.5+,Java 17+(
jakarta.*命名空间,原javax.*已迁移)
目录
- 第一层:局部异常处理 @ExceptionHandler
- 第二层:全局异常处理 @RestControllerAdvice
- 自定义异常体系
- 常见内置异常处理
- ResponseBodyAdvice:自动包装成功响应
- 速查清单
1. 第一层:局部异常处理 @ExceptionHandler
问题:散落的 try-catch
书店应用里有两个接口——查书和查评论,它们都需要处理"书不存在"的情况:
java
// BAD:每个方法自己 try-catch,处理逻辑完全相同却重复两遍
// 注意:bookService / reviewService 省略注入声明,焦点在 try-catch 结构上
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping("/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
try {
Book book = bookService.findById(id); // ← Service 层抛 BookNotFoundException
return ResponseEntity.ok(book);
} catch (BookNotFoundException e) {
return ResponseEntity.status(404).body(null); // ← 重复
}
}
@GetMapping("/{id}/reviews")
public ResponseEntity<List<Review>> getReviews(@PathVariable Long id) {
try {
List<Review> reviews = reviewService.findByBookId(id); // ← Service 层抛
return ResponseEntity.ok(reviews);
} catch (BookNotFoundException e) {
return ResponseEntity.status(404).body(null); // ← 完全相同的 catch,复制粘贴
}
}
}两个方法里出现了完全相同的 catch 块。错误信息格式要改时,每个方法都要改一遍。
上例中的 BookNotFoundException 定义非常简单——就是一个普通的自定义异常:
java
// 最简单的自定义异常,继承 RuntimeException 即可
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book with id " + id + " not found");
}
}Service 层在查不到数据时抛出它:
java
@Service
public class BookService {
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
}现在来看看 @ExceptionHandler 如何消除 Controller 中的重复。
用 @ExceptionHandler 消除重复
@ExceptionHandler 可以在 Controller 内部声明一个专门的异常处理方法。同一 Controller 内所有方法抛出的指定异常,都会被它统一拦截:
java
// GOOD:异常由 Service 层抛出,Controller 不用 try-catch,全部交给 @ExceptionHandler
@RestController
@RequestMapping("/books")
public class BookController {
private final BookService bookService;
private final ReviewService reviewService;
// 构造器注入(Spring 4.3+ 单构造器场景可省略 @Autowired)
public BookController(BookService bookService, ReviewService reviewService) {
this.bookService = bookService;
this.reviewService = reviewService;
}
@GetMapping("/{id}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id); // Service 层抛异常 → 自动路由到下面 handler
}
@GetMapping("/{id}/reviews")
public List<Review> getReviews(@PathVariable Long id) {
return reviewService.findByBookId(id); // 同上,Service 抛出 → 自动路由
}
// 在同一个 Controller 内,专门处理 BookNotFoundException
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, String> handleBookNotFound(BookNotFoundException e) {
return Map.of("error", e.getMessage());
// 两个方法抛出的 BookNotFoundException 都落这里,改一处全生效
}
}@ExceptionHandler 方法的参数是要拦截的异常类型,Spring 在该 Controller 范围内自动路由匹配的异常。
本节回顾
请求 → Controller 方法 → 抛 BookNotFoundException
↓
同 Controller 内的 @ExceptionHandler
↓
返回 404 错误响应局限:
@ExceptionHandler只对当前 Controller 生效。如果 50 个 Controller 都要处理BookNotFoundException,还是要复制 50 份。→ 第二层解决这个问题。
2. 第二层:全局异常处理 @RestControllerAdvice
新概念:@RestControllerAdvice
@RestControllerAdvice = @ControllerAdvice + @ResponseBody。
@ControllerAdvice:让这个类能"增强"所有 Controller(AOP 横切关注点,无需修改每个 Controller)@ResponseBody:让返回值自动序列化为 JSON
把所有 @ExceptionHandler 方法集中到这个类里,一次性覆盖整个应用:
java
// 全局异常处理器:覆盖所有 Controller,写一次到处生效
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理所有 Controller 抛出的 BookNotFoundException
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleBookNotFound(BookNotFoundException e) {
return buildResponse(HttpStatus.NOT_FOUND, e.getMessage());
}
// 处理所有 Controller 抛出的 IllegalArgumentException
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException e) {
return buildResponse(HttpStatus.BAD_REQUEST, e.getMessage());
}
// 统一构建方法:一处定义数据结构,所有 handler 复用
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}Controller 完全不需要关心异常处理:
java
// Controller 只专注业务逻辑,异常直接往上抛
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping("/{id}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id); // 异常自动被 GlobalExceptionHandler 捕获
}
}异常路由优先级
Controller 内的 @ExceptionHandler 和全局 @RestControllerAdvice 都能处理同一异常时,Controller 内的优先:
抛出异常
↓
当前 Controller 内有 @ExceptionHandler?
├─ YES → 局部处理(优先级更高)
└─ NO → 找 @RestControllerAdvice
↓
有精确匹配的 @ExceptionHandler?
├─ YES → 全局处理
└─ NO → 向父类匹配 → 直到 Exception 兜底 → Spring 默认处理(500)本节回顾
BookController OrderController UserController
│ │ │
└─────────────────┴─────────────────┘
│ 任意异常上浮
▼
GlobalExceptionHandler
(@RestControllerAdvice)
│
统一格式响应给客户端3. 自定义异常体系
演进预告:前面直接用
BookNotFoundException extends RuntimeException,简单场景够用。但业务复杂后,每种异常写一个 handler 会爆炸,且错误码格式参差不齐。本节引入一套结构化的异常体系:所有业务异常共用一个基类、统一定义错误码,只需要一个 handler 方法处理全部业务异常。
继承谁?
Java 异常的继承树:
Throwable
├── Error(JVM 级别,如 OutOfMemoryError,不捕获处理)
└── Exception
├── IOException(受检异常:必须 try-catch 或 throws 声明)
└── RuntimeException(非受检异常:不强制处理,自动向上传播)
├── NullPointerException
├── IllegalArgumentException
└── ← 自定义业务异常推荐继承这里Web 应用推荐继承 RuntimeException:不需要在方法签名写 throws,异常自然传播到全局处理器。
设计基础异常类
业务异常需要携带两个信息:错误码(供程序判断分支)和 HTTP 状态码(供处理器设置响应状态):
java
import org.springframework.http.HttpStatus;
// 所有业务异常的公共基类
public class BusinessException extends RuntimeException {
private final String code; // 业务错误码,如 "BOOK_NOT_FOUND"
private final HttpStatus httpStatus; // 对应的 HTTP 状态码
public BusinessException(String code, String message, HttpStatus httpStatus) {
super(message);
this.code = code;
this.httpStatus = httpStatus;
}
public String getCode() { return code; }
public HttpStatus getHttpStatus(){ return httpStatus; }
}按业务场景定义子类
每种具体业务场景继承基类,在构造方法里预设好错误码和状态码:
java
// 资源不存在 → 404(前面 BookNotFoundException 的结构化版本)
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resourceName, Object id) {
super(
resourceName.toUpperCase() + "_NOT_FOUND",
resourceName + " with id " + id + " not found",
HttpStatus.NOT_FOUND
);
}
}
// 业务规则冲突(如库存不足、重复下单)→ 409
public class BusinessConflictException extends BusinessException {
public BusinessConflictException(String code, String message) {
super(code, message, HttpStatus.CONFLICT);
}
}
// 无权限操作 → 403
public class ForbiddenException extends BusinessException {
public ForbiddenException(String message) {
super("FORBIDDEN", message, HttpStatus.FORBIDDEN);
}
}全局处理器只需一个 handler
有了统一的基类,所有业务异常的处理逻辑合并为一个 handler:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 一个 handler 覆盖 BusinessException 及其所有子类
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, Object>> handleBusinessException(BusinessException e) {
return buildResponse(e.getHttpStatus(), e.getCode(), e.getMessage());
}
// §2 版本:code 取 HTTP 状态码数值
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
// 业务异常版本:code 由异常对象携带(如 "BOOK_NOT_FOUND")
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String code, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}Service 层直接抛,不再关心 HTTP 细节:
java
@Service
public class BookService {
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book", id));
// → 抛出后由 GlobalExceptionHandler 捕获
// → 响应:HTTP 404 + {"code":"BOOK_NOT_FOUND","message":"Book with id 99 not found"}
}
public void placeOrder(Long bookId, int quantity) {
Book book = findById(bookId);
if (book.getStock() < quantity) {
throw new BusinessConflictException(
"INSUFFICIENT_STOCK",
"库存不足,当前库存:" + book.getStock()
);
// → 响应:HTTP 409 + {"code":"INSUFFICIENT_STOCK","message":"库存不足,当前库存:3"}
}
}
}本节回顾
BusinessException(基类:code + httpStatus)
├── ResourceNotFoundException → 404
├── BusinessConflictException → 409
└── ForbiddenException → 403
│
│ 全部被一个 handler 捕获
▼
GlobalExceptionHandler.handleBusinessException()
│
ResponseEntity.status(e.getHttpStatus()).body(...)4. 常见内置异常处理
框架本身会抛出多种内置异常,需要在全局处理器里逐一处理,否则客户端收到的是 Spring 默认的不友好格式。
请求处理的两个阶段:同为 400,
HttpMessageNotReadableException和MethodArgumentNotValidException容易混淆,但它们触发时机完全不同:请求进入 │ ▼ ① 反序列化:JSON → DTO 对象 ├─ JSON 格式非法 / 类型无法转换 → HttpMessageNotReadableException(§4.3) └─ 反序列化成功,得到 DTO 对象 │ ▼ ② 校验:@Valid 检查 DTO 字段 ├─ 校验失败 → MethodArgumentNotValidException(§4.1) └─ 校验通过 → 进入 Controller 方法关键区别:前者拿不到 DTO(JSON 都没解析出来),只能返回"格式错误";后者拿到了 DTO,可以定位到具体哪个字段不符、逐字段返回错误详情。
4.1 参数校验失败:MethodArgumentNotValidException
Controller 方法参数加了 @Valid 注解,Bean Validation 校验不通过时抛出此异常。
触发端(Controller 必须有 @Valid):
java
// DTO:字段上挂校验注解(Spring Boot 3.x 使用 jakarta.validation.*)
public class CreateBookRequest {
@NotBlank(message = "书名不能为空") // jakarta.validation.constraints.NotBlank
private String title;
@Min(value = 0, message = "价格不能为负数") // jakarta.validation.constraints.Min
private BigDecimal price;
// getter / setter ...
}
// Controller:@Valid 触发校验 ← 不加这个注解,校验不会执行
@PostMapping("/books")
public Book createBook(@Valid @RequestBody CreateBookRequest request) { // jakarta.validation.Valid
return bookService.create(request);
}提示:
@Valid触发校验 → 失败抛MethodArgumentNotValidException→ 落入下面的 handler 返回 400。@NotBlank、@Min等校验注解体系是独立主题,这里只需记住链路。
全局处理器提取字段错误(沿用 §2 的 buildResponse 模式):
java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
String errorMsg = e.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
return buildResponse(HttpStatus.BAD_REQUEST, errorMsg);
}响应示例:
json
{
"code": 400,
"message": "title: 书名不能为空; price: 价格不能为负数"
}4.2 路由不存在:NoHandlerFoundException / NoResourceFoundException
Spring Boot 3.5 中,404 场景分两类:
NoHandlerFoundException:请求路径没有匹配的@RequestMapping处理器NoResourceFoundException(Spring Framework 6.2+ 新增):路径命中了ResourceHttpRequestHandler,但对应静态资源文件不存在
只需一条配置即可让两者都抛出异常(spring.mvc.throw-exception-if-no-handler-found 在 Spring Boot 3.4 已废弃,3.5 无需再配):
yaml
# application.yml
spring:
web:
resources:
add-mappings: false # 关闭静态资源默认映射,让 404 场景抛出异常全局处理器同时捕获两者:
java
// 请求路径无匹配 Handler(路由不存在)
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoHandler(NoHandlerFoundException e) {
return buildResponse(HttpStatus.NOT_FOUND,
"接口 [" + e.getHttpMethod() + " " + e.getRequestURL() + "] 不存在");
}
// 静态资源路径无匹配文件(Spring Framework 6.2+)
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoResource(NoResourceFoundException e) {
return buildResponse(HttpStatus.NOT_FOUND,
"资源 [" + e.getHttpMethod() + " " + e.getResourcePath() + "] 不存在");
}4.3 反序列化失败:HttpMessageNotReadableException
请求体 JSON 无法解析为目标 DTO 类型时抛出,发生在 @Valid 校验之前(见本节开头流程图)。常见触发场景:
- JSON 语法错误(缺少引号、逗号、花括号不匹配)
- 字段类型不匹配(如
"price": "abc"期望BigDecimal) - 请求体为空或
Content-Type不正确
与 §4.1 的区别:此异常拿不到 DTO 对象,无法给出字段级错误详情,只能返回"请求体格式错误"。
java
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handleMessageNotReadable(HttpMessageNotReadableException e) {
return buildResponse(HttpStatus.BAD_REQUEST,
"请求体格式错误,请检查 JSON 格式");
// 注意:不要把 e.getMessage() 直接返给客户端,可能暴露内部实现细节
}本节回顾
POST /books(@Valid 校验失败)
→ MethodArgumentNotValidException → 400 + 字段错误列表
GET /not-exist-path(无匹配 Handler)
→ NoHandlerFoundException → 404 + 路由不存在提示
GET /static/missing.png(静态资源文件不存在,Spring Framework 6.2+)
→ NoResourceFoundException → 404 + 资源不存在提示
POST /books(JSON 格式错误)
→ HttpMessageNotReadableException → 400 + 格式错误提示5. ResponseBodyAdvice:自动包装成功响应
问题:成功/失败格式不一致
§2~§4 已统一错误响应格式(buildResponse() → {code, message, timestamp, path}),但成功响应仍是 Controller 返回的裸对象:
| 场景 | 响应 |
|---|---|
| 成功 GET /books/1 | {"id":1,"title":"Java 编程思想"} |
| 失败 GET /books/99 | {"code":404,"message":"...","timestamp":"...","path":"..."} |
前端仍需根据 HTTP 状态码分两套逻辑处理。ResponseBodyAdvice 解决这个问题——在成功响应写出前自动包一层统一格式。
ResponseBodyAdvice 实现
java
@RestControllerAdvice
public class GlobalResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
//只有被 @RestController 注解标注的类(即纯 JSON 接口),才需要进入 beforeBodyWrite 进行包装
return returnType.getContainingClass().isAnnotationPresent(RestController.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType, Class selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
// 已是 Map 且含 code 字段 → 异常处理器返回的,不重复包装
if (body instanceof Map && ((Map<?, ?>) body).containsKey("code")) {
return body;
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 200);
result.put("message", "success");
result.put("data", body);
return result;
}
}
supports()白名单:建议限定@RestController,避免包装 Swagger JSON、Actuator 健康检查等内部端点。String 返回值陷阱:若 Controller 返回
String,beforeBodyWrite中包装为Map会导致类型转换异常(StringHttpMessageConverter无法处理Map对象)。解决方案:① 始终返回对象而非 String ② 在 Advice 中单独处理 String 类型,手动用 Jackson 序列化。
Controller 回归纯粹——只管业务:
java
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping("/{id}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id); // 自动包装为统一格式
}
@PostMapping
public Book createBook(@Valid @RequestBody CreateBookRequest request) {
return bookService.create(request); // 自动包装
}
}本节回顾
前端
│
┌──────────┴──────────┐
│ │
成功响应 失败响应
ResponseBodyAdvice GlobalExceptionHandler
自动包装 Map buildResponse() 手动构建
│ │
└──────────┬──────────┘
│
{ code, message, ... }
(格式统一,code 字段标识成功/失败)6. 速查清单
6.1 常见内置异常速查
┌─────────────────────────────────────────┬──────────────────────────────────┬────────┐
│ 异常类 │ 触发场景 │ 状态码 │
├─────────────────────────────────────────┼──────────────────────────────────┼────────┤
│ MethodArgumentNotValidException │ @Valid 对 @RequestBody 校验失败 │ 400 │
│ ConstraintViolationException │ @Validated 对方法参数校验失败 │ 400 │
│ HttpMessageNotReadableException │ JSON 格式非法 / 类型不匹配 │ 400 │
│ MissingServletRequestParameterException │ 缺少必填 @RequestParam │ 400 │
│ MethodArgumentTypeMismatchException │ 路径变量类型不匹配(如传 "abc" 给Long)│ 400 │
│ NoHandlerFoundException │ 路由不存在(无匹配 Handler) │ 404 │
│ NoResourceFoundException(6.2+) │ 静态资源文件不存在 │ 404 │
│ HttpRequestMethodNotSupportedException │ HTTP 方法不对(POST 请求 GET 接口)│ 405 │
│ BusinessException(自定义基类) │ 业务规则违反 │ 由异常携带│
│ Exception(兜底) │ 未预期异常 │ 500 │
└─────────────────────────────────────────┴──────────────────────────────────┴────────┘6.2 自定义异常层次结构
BusinessException(基类)
├── 字段:code(String)+ httpStatus(HttpStatus)
├── 继承:RuntimeException(无需 throws 声明)
│
├── ResourceNotFoundException → 404 (资源不存在)
├── BusinessConflictException → 409 (库存不足、重复操作等)
├── ForbiddenException → 403 (无权限)
└── (按业务需要扩展更多子类)6.3 @ExceptionHandler 方法签名速查
java
// 场景1:需要自定义 HTTP 状态码(通过 buildResponse 统一构建)
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, Object>> handle(
BusinessException e, HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", e.getCode());
body.put("message", e.getMessage());
return ResponseEntity.status(e.getHttpStatus()).body(body);
}
// 场景2:固定 HTTP 状态码(注入 HttpServletRequest 获取 path)
@ExceptionHandler(XxxException.class)
public ResponseEntity<Map<String, Object>> handle(
XxxException e, HttpServletRequest request) {
return buildResponse(HttpStatus.BAD_REQUEST, request, e.getMessage());
}
// 场景3:同一方法处理多种异常
@ExceptionHandler({AException.class, BException.class})
public ResponseEntity<Map<String, Object>> handle(
Exception e, HttpServletRequest request) { ... }
// 场景4:兜底(捕获所有未处理异常)
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleAll(
Exception e, HttpServletRequest request) { ... }6.4 处理顺序与优先级
Controller 内 @ExceptionHandler(最高优先级)
↓ 未匹配
@RestControllerAdvice 中精确类型匹配
↓ 未匹配
@RestControllerAdvice 中父类匹配(如 BusinessException 基类)
↓ 未匹配
@RestControllerAdvice 中 Exception 兜底
↓ 未匹配
Spring 默认处理 → /error 端点 → Whitelabel Error Page6.5 统一响应格式
┌────────────┬─────────────┬──────────────────────────────────────────────────────┐
│ 字段 │ 类型 │ 说明 │
├────────────┼─────────────┼──────────────────────────────────────────────────────┤
│ code │ int │ 成功为 200;失败为对应 HTTP 状态码(404、400 等) │
│ message │ String │ 人类可读的描述,可直接展示给用户 │
│ data │ Object │ 成功时携带业务数据;失败时无此字段 │
│ timestamp │ String │ 失败响应附带,ISO-8601 时间戳,用于生产排查 │
│ path │ String │ 失败响应附带,请求路径,用于定位问题接口 │
└────────────┴─────────────┴──────────────────────────────────────────────────────┘6.6 404 异常必要配置(Spring Boot 3.5)
yaml
# application.yml
# 只需一条配置,throw-exception-if-no-handler-found 在 Spring Boot 3.4 已废弃,无需再加
spring:
web:
resources:
add-mappings: false # 关闭静态资源默认映射,404 场景才能抛出异常升级注意:从 Spring Boot 2.x 迁移时,可删除
spring.mvc.throw-exception-if-no-handler-found: true,仅保留上方配置即可。
6.7 ResponseBodyAdvice 自动包装速查
java
// 实现要点
@RestControllerAdvice
public class GlobalResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
//只有被 @RestController 注解标注的类(即纯 JSON 接口),才需要进入 beforeBodyWrite 进行包装
return returnType.getContainingClass().isAnnotationPresent(RestController.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType, Class selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
// 已是 Map 且含 code → 异常处理器返回的,跳过
if (body instanceof Map && ((Map<?, ?>) body).containsKey("code")) {
return body;
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 200);
result.put("message", "success");
result.put("data", body);
return result;
}
}┌───────────────────────────┬───────────────────────────────────────────────────────┐
│ 注意事项 │ 说明 │
├───────────────────────────┼───────────────────────────────────────────────────────┤
│ 避免双重包装 │ 判断 body instanceof Map && containsKey("code") │
│ 限定拦截范围 │ supports() 白名单,避免包装 Swagger/Actuator 等内部端点 │
│ String 返回值陷阱 │ StringHttpMessageConverter 无法处理 Map,建议不返回 String │
│ 与 @ExceptionHandler 协作 │ Advice 拦截成功响应,Handler 处理失败响应,分工明确 │
└───────────────────────────┴───────────────────────────────────────────────────────┘