Note 14. 健壮性保障:统一响应封装与全局异常处理

Note 14. 健壮性保障:统一响应封装与全局异常处理

摘要:一个工业级的后端 API,其优雅之处不仅在于成功时的响应结构清晰,更在于失败时的反馈准确且安全。直接向前端暴露原始的 Java 异常堆栈,是业余开发者的典型特征,这不仅增加了前后端的沟通成本,更可能泄露数据库表结构等敏感信息。本章我们将从零构建一套坚不可摧的"防御体系":首先设计统一响应结构,建立前后端之间严格的"通信契约";其次深入理解 Java 异常体系与 Spring 事务回滚的底层关联;最后利用 Spring MVC 的全局异常处理机制,实现从参数校验到系统崩溃的全链路异常自动化处理,并将这一切与上一章学习的 JSR-303 校验体系完美融合。

本章学习路径

  1. 问题溯源:深入分析传统 API 响应的混乱现状,理解统一响应体的必要性和设计原则。
  2. 契约设计:从零设计状态码枚举和通用响应体,掌握 HTTP 状态码与业务状态码的协作关系。
  3. 异常体系:深入 Java 异常分类,理解受检异常与非受检异常的本质差异,设计自定义业务异常。
  4. 拦截机制:剖析 Spring MVC 异常处理流程,掌握全局异常处理器的工作原理。
  5. 全面覆盖:处理参数校验异常、业务异常、系统异常、404错误、方法不支持等各类场景。
  6. 字段级反馈:实现参数校验失败时,向前端返回字段级的错误信息。
  7. 实战演练:通过完整的用户注册案例,验证整套体系的有效性。

14.1. 从混乱到秩序:API 响应的演进之路

14.1.1. 传统 API 的五种混乱模式

在正式设计统一响应体之前,我们先来审视一个真实项目中可能出现的各种响应格式。假设我们正在开发一个用户管理系统,包含查询、新增、更新、删除等功能。

模式一:直接返回实体对象

1
2
3
4
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUserById(id);
}

当用户存在时,前端收到:

1
2
3
4
5
6
{
"id": 1,
"username": "zhangsan",
"email": "zhangsan@example.com",
"createTime": "2025-01-01T10:00:00"
}

问题分析

  • 前端无法通过响应体判断操作是否成功,只能依赖 HTTP 状态码
  • 当用户不存在时会抛出异常,前端收到的可能是 HTML 错误页或不规范的 JSON
  • 缺少提示信息,前端需要自己编写业务提示语

模式二:直接返回布尔值或字符串

1
2
3
4
5
@DeleteMapping("/users/{id}")
public String deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return "删除成功";
}

前端收到的是纯文本:删除成功

问题分析

  • 响应格式不是 JSON,前端无法统一处理
  • 失败时可能返回"删除失败",前端需要通过字符串匹配来判断成功与否
  • 无法携带额外数据(如删除的记录数)

模式三:随意自定义的 JSON 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/users")
public Map<String, Object> createUser(@RequestBody User user) {
Map<String, Object> result = new HashMap<>();
try {
userService.save(user);
result.put("success", true);
result.put("msg", "创建成功");
} catch (Exception e) {
result.put("success", false);
result.put("error", e.getMessage());
}
return result;
}

成功时返回:

1
2
3
4
{
"success": true,
"msg": "创建成功"
}

失败时返回:

1
2
3
4
{
"success": false,
"error": "用户名已存在"
}

问题分析

  • 成功和失败的字段名不一致(msg vs error
  • 不同接口可能用不同的字段名(有的用 message,有的用 msg
  • 前端需要针对每个接口编写不同的解析逻辑

模式四:Spring Boot 默认的错误响应

当代码抛出异常时,Spring Boot 返回默认的错误页面或 JSON:

1
2
3
4
5
6
7
{
"timestamp": "2025-12-17T10:15:30.000+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "用户名已存在",
"path": "/users"
}

问题分析

  • 包含了技术细节(如 pathstatus),不适合直接展示给用户
  • 字段名与我们自定义的成功响应不一致
  • 没有业务状态码,只有 HTTP 状态码

模式五:不同开发者的个人风格

1
2
3
4
5
6
7
8
// 开发者 A 的风格
return Map.of("code", 200, "data", user);

// 开发者 B 的风格
return Map.of("status", "ok", "result", user);

// 开发者 C 的风格
return Map.of("errcode", 0, "errmsg", "success", "data", user);

问题分析

  • 团队内部缺乏统一标准
  • 前端需要适配多种不同的格式
  • 代码审查和维护成本极高

14.1.2. 混乱现状的根本原因

通过上面的案例,我们可以总结出传统 API 响应混乱的三大根本原因:

维度问题表现影响
缺乏契约没有统一的响应格式规范前端需要针对每个接口编写不同的处理逻辑
职责不清Controller 既要处理业务,又要处理异常封装代码臃肿,逻辑混乱
安全隐患直接向前端暴露异常堆栈或数据库错误信息可能泄露敏感信息,为攻击者提供线索

14.1.3. 理想的统一响应体应该是什么样的?

在设计统一响应体之前,我们先明确它应该满足哪些要求:

核心要求

  1. 结构统一:所有接口的响应都使用相同的 JSON 结构
  2. 信息完整:包含状态码、提示信息、业务数据
  3. 易于扩展:可以方便地添加新的字段(如 traceId、timestamp)
  4. 类型安全:使用泛型,避免 Object 类型
  5. 对前端友好:提示信息应该是人类可读的,而非技术术语

设计目标

无论是成功还是失败,前端都能收到类似下面的统一格式:

成功响应示例

1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "zhangsan"
},
"timestamp": 1734422400000
}

失败响应示例

1
2
3
4
5
{
"code": 5002,
"message": "用户名已存在",
"timestamp": 1734422400000
}

参数校验失败示例

1
2
3
4
5
6
7
8
9
{
"code": 4000,
"message": "参数校验失败",
"data": {
"username": "用户名长度需在4-20字符之间",
"email": "邮箱格式不正确"
},
"timestamp": 1734422400000
}

14.1.4. HTTP 状态码 vs 业务状态码:职责划分

在设计状态码之前,我们必须先理解 HTTP 状态码和业务状态码的区别。这是很多初学者容易混淆的地方。

HTTP 状态码的定位

HTTP 状态码是网络协议层面的标准,由 RFC 7231 规范定义,用于表示 HTTP 请求的处理结果。

HTTP 状态码范围含义典型场景
2xx请求成功被服务器接收、理解并处理200 OK, 201 Created
3xx需要客户端进一步操作才能完成请求301 永久重定向, 302 临时重定向
4xx客户端错误,请求包含语法错误或无法完成请求400 参数错误, 401 未认证, 403 无权限, 404 不存在
5xx服务器错误,服务器在处理请求时发生了错误500 内部错误, 502 网关错误, 503 服务不可用

业务状态码的定位

业务状态码是应用层面的自定义编码,用于表示具体的业务处理结果。

核心设计原则

  1. HTTP 状态码用于表示"通信状态"

    • 200:服务器成功接收请求并返回了响应(不管业务成功还是失败)
    • 400:请求格式错误(如 JSON 格式不正确)
    • 401:未登录或 Token 失效
    • 403:已登录但无权限
    • 404:请求的资源路径不存在
    • 500:服务器内部错误
  2. 业务状态码用于表示"业务结果"

    • 200:业务处理成功
    • 4000:参数校验失败
    • 5001:用户不存在
    • 5002:用户名已存在
    • 5101:库存不足

实际应用场景对比

场景HTTP 状态码业务状态码说明
用户注册成功200200通信成功,业务成功
用户名已存在2005002通信成功,业务失败(这是预期内的业务逻辑)
参数校验失败4004000客户端参数错误
用户未登录4014001认证失败
接口不存在4044004路径不存在
数据库挂了500500服务器内部错误

为什么"用户名已存在"返回 HTTP 200?

这是很多初学者的疑问。让我们通过一个对比来理解:

错误做法(HTTP 状态码与业务状态混用):

1
2
3
4
5
6
7
8
@PostMapping("/users")
public ResponseEntity<String> register(@RequestBody UserDTO dto) {
if (userService.existsByUsername(dto.getUsername())) {
return ResponseEntity.status(400).body("用户名已存在");
}
userService.save(dto);
return ResponseEntity.ok("注册成功");
}

问题

  • 前端收到 HTTP 400,可能会误以为是请求参数格式错误
  • 浏览器的 Network 面板会标红显示,但这其实不是网络错误
  • 在微服务架构中,网关可能会拦截 400 响应,认为是客户端错误

正确做法(HTTP 状态码表示通信,业务状态码表示结果):

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/users")
public Result<Void> register(@RequestBody @Validated UserDTO dto) {
userService.register(dto); // 内部会抛出 BusinessException
return Result.success();
}

// Service 层
public void register(UserDTO dto) {
if (existsByUsername(dto.getUsername())) {
throw new BusinessException(ResultCode.USER_ALREADY_EXIST); // 业务状态码 5002
}
save(dto);
}

响应(HTTP 200):

1
2
3
4
5
{
"code": 5002,
"message": "用户名已存在",
"timestamp": 1734422400000
}

优势

  • HTTP 200 表示服务器成功处理了请求
  • 业务状态码 5002 明确告知是"用户名已存在"的业务问题
  • 浏览器 Network 面板显示正常,不会误导开发者
  • 在微服务架构中,网关不会误判为通信错误

14.2. 核心基建:从零构建响应体模型

14.2.1. 第一步:设计状态码枚举

状态码的设计需要遵循一定的规范,避免出现"魔法数字"。我们将状态码设计为枚举类型,这样可以:

  1. 提供代码提示和自动补全
  2. 避免拼写错误
  3. 集中管理所有状态码
  4. 方便后期维护和扩展

设计原则

状态码范围用途示例
200成功200: 操作成功
500系统级错误(非预期)500: 系统繁忙,请稍后重试
4000-4999客户端错误(参数、权限等)4000: 参数校验失败
4001: 未登录
4003: 无权限
4004: 资源不存在
5000-5999业务逻辑错误(预期内)5001: 用户不存在
5002: 用户名已存在
5101: 库存不足

文件路径src/main/java/com/example/demo/common/ResultCode.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package com.example.demo.common;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
* 统一响应状态码枚举
*
* 设计原则:
* 1. 200 表示业务处理成功
* 2. 500 表示系统内部错误(如空指针、数据库异常等非预期错误)
* 3. 4xxx 表示客户端问题(参数错误、权限不足等)
* 4. 5xxx 表示业务逻辑校验未通过(用户不存在、库存不足等预期内的业务问题)
*/
@Getter
@AllArgsConstructor
public enum ResultCode {

// ==================== 通用状态 ====================

/**
* 操作成功
*/
SUCCESS(200, "操作成功"),

/**
* 系统内部错误
* 注意:此消息会返回给前端,因此使用模糊化的描述,具体错误信息通过日志记录
*/
FAILURE(500, "系统繁忙,请稍后重试"),

// ==================== 客户端错误 (4000-4999) ====================

/**
* 参数校验失败
* 由 JSR-303 校验框架触发
*/
PARAM_VALID_ERROR(4000, "参数校验失败"),

/**
* 未登录或 Token 已失效
*/
UNAUTHORIZED(4001, "您还未登录或登录已失效,请重新登录"),

/**
* 无权限访问
*/
FORBIDDEN(4003, "您没有权限执行此操作"),

/**
* 请求的资源不存在
*/
NOT_FOUND(4004, "请求的资源不存在"),

/**
* 请求方法不支持
* 例如:接口只支持 POST,但客户端发送了 GET 请求
*/
METHOD_NOT_SUPPORTED(4005, "不支持该请求方法"),

/**
* 请求的 Content-Type 不支持
*/
MEDIA_TYPE_NOT_SUPPORTED(4006, "不支持的媒体类型"),

// ==================== 业务逻辑错误 (5000-5999) ====================

// --- 用户模块 (5001-5099) ---

/**
* 用户不存在
*/
USER_NOT_EXIST(5001, "用户不存在"),

/**
* 用户已存在(注册时用户名或邮箱重复)
*/
USER_ALREADY_EXIST(5002, "用户名或邮箱已被注册"),

/**
* 账号已被冻结
*/
USER_ACCOUNT_LOCKED(5003, "您的账号已被冻结,请联系管理员"),

/**
* 用户名或密码错误
*/
USER_PASSWORD_ERROR(5004, "用户名或密码错误"),

/**
* 验证码错误
*/
USER_VERIFY_CODE_ERROR(5005, "验证码错误或已失效"),

// --- 订单模块 (5101-5199) ---

/**
* 库存不足
*/
ORDER_STOCK_NOT_ENOUGH(5101, "商品库存不足"),

/**
* 订单状态异常(例如:已取消的订单无法支付)
*/
ORDER_STATUS_INVALID(5102, "订单状态异常,无法执行此操作"),

/**
* 订单不存在
*/
ORDER_NOT_EXIST(5103, "订单不存在"),

// --- 支付模块 (5201-5299) ---

/**
* 余额不足
*/
BALANCE_NOT_ENOUGH(5201, "账户余额不足"),

/**
* 第三方支付服务异常
*/
THIRD_PARTY_SERVICE_ERROR(5202, "支付服务暂时不可用,请稍后重试");

/**
* 状态码
*/
private final int code;

/**
* 提示信息
*/
private final String message;
}

设计细节解析

  1. 使用 Lombok 简化代码

    • @Getter:自动生成 getCode()getMessage() 方法
    • @AllArgsConstructor:自动生成全参数构造器
  2. 分组管理

    • 用注释将不同模块的状态码分组
    • 每个模块预留一定的编号空间(如用户模块 5001-5099)
    • 便于后期扩展,不会出现编号冲突
  3. JavaDoc 注释

    • 每个枚举值都有详细的说明
    • 说明适用场景和触发条件
    • 方便团队成员查阅

14.2.2. 第二步:设计通用响应体 Result<T>

响应体的设计是整个系统的"门面",需要仔细推敲每个字段的必要性和合理性。

设计考量

  1. 字段选择

    • code:必需,业务状态码
    • message:必需,提示信息
    • data:可选,业务数据(查询结果、创建的对象等)
    • timestamp:可选但推荐,用于问题排查
  2. 泛型设计

    • 使用 <T> 而不是 Object,提供类型安全
    • 让 IDE 和 Swagger 能够自动推断数据类型
  3. 序列化优化

    • 使用 @JsonInclude(NON_NULL) 避免返回 "data": null
    • 减少响应体大小,优化网络传输
  4. 构造器封装

    • 使用私有构造器 + 静态工厂方法
    • 强制使用 Result.success() 等标准方法创建对象
    • 避免开发者遗漏必要字段

文件路径src/main/java/com/example/demo/common/Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
package com.example.demo.common;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;

/**
* 统一 API 响应结果封装
*
* 设计原则:
* 1. 所有接口统一返回此类型
* 2. 成功和失败使用相同的 JSON 结构
* 3. 使用泛型 T 保证类型安全
* 4. data 为 null 时不序列化该字段(通过 @JsonInclude 实现)
*
* @param <T> 响应数据的类型
*/
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> {

/**
* 业务状态码
* 200 表示成功,其他表示失败
*/
private final int code;

/**
* 提示信息
* 成功时通常是 "操作成功"
* 失败时是具体的错误提示(如 "用户名已存在")
*/
private final String message;

/**
* 响应数据
* 查询操作时返回查询结果
* 新增操作时可以返回新增对象的 ID
* 删除、修改操作时通常为 null
*/
private final T data;

/**
* 响应时间戳(毫秒)
* 用于前端显示响应时间,或后端排查问题时关联日志
*/
private final long timestamp;

/**
* 私有构造器,禁止外部直接 new
* 强制使用静态工厂方法创建对象,确保字段完整性
*/
private Result(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
this.timestamp = System.currentTimeMillis();
}

// ==================== 成功响应工厂方法 ====================

/**
* 成功响应,无返回数据
*
* 适用场景:
* - 删除操作
* - 更新操作
* - 其他不需要返回业务数据的操作
*
* 响应示例:
* {
* "code": 200,
* "message": "操作成功",
* "timestamp": 1734422400000
* }
*
* @return Result对象
*/
public static <T> Result<T> success() {
return new Result<>(
ResultCode.SUCCESS.getCode(),
ResultCode.SUCCESS.getMessage(),
null
);
}

/**
* 成功响应,带返回数据
*
* 适用场景:
* - 查询操作(返回查询结果)
* - 新增操作(返回新增对象的ID或完整对象)
*
* 响应示例:
* {
* "code": 200,
* "message": "操作成功",
* "data": { "id": 1, "username": "zhangsan" },
* "timestamp": 1734422400000
* }
*
* @param data 业务数据
* @return Result对象
*/
public static <T> Result<T> success(T data) {
return new Result<>(
ResultCode.SUCCESS.getCode(),
ResultCode.SUCCESS.getMessage(),
data
);
}

/**
* 成功响应,自定义提示信息
*
* 适用场景:
* - 需要返回特定提示语的成功操作
*
* 响应示例:
* {
* "code": 200,
* "message": "邮件发送成功",
* "timestamp": 1734422400000
* }
*
* @param message 自定义提示信息
* @return Result对象
*/
public static <T> Result<T> success(String message) {
return new Result<>(
ResultCode.SUCCESS.getCode(),
message,
null
);
}

/**
* 成功响应,自定义提示信息 + 返回数据
*
* @param message 自定义提示信息
* @param data 业务数据
* @return Result对象
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(
ResultCode.SUCCESS.getCode(),
message,
data
);
}

// ==================== 失败响应工厂方法 ====================

/**
* 失败响应,使用枚举定义的状态码和消息
*
* 适用场景:
* - 标准的业务错误(如用户不存在、权限不足等)
*
* 响应示例:
* {
* "code": 5001,
* "message": "用户不存在",
* "timestamp": 1734422400000
* }
*
* @param resultCode 状态码枚举
* @return Result对象
*/
public static <T> Result<T> error(ResultCode resultCode) {
return new Result<>(
resultCode.getCode(),
resultCode.getMessage(),
null
);
}

/**
* 失败响应,使用枚举的状态码,但覆盖消息
*
* 适用场景:
* - 需要在枚举定义的基础上,提供更详细的错误信息
* - 例如:ResultCode.PARAM_VALID_ERROR 默认是 "参数校验失败",
* 但我们需要告诉前端具体哪个字段错误
*
* 响应示例:
* {
* "code": 4000,
* "message": "用户名长度需在4-20字符之间",
* "timestamp": 1734422400000
* }
*
* @param resultCode 状态码枚举
* @param customMessage 自定义错误信息
* @return Result对象
*/
public static <T> Result<T> error(ResultCode resultCode, String customMessage) {
return new Result<>(
resultCode.getCode(),
customMessage,
null
);
}

/**
* 失败响应,完全自定义状态码和消息
*
* 注意:此方法应该谨慎使用,优先使用枚举定义的状态码
*
* 适用场景:
* - 处理第三方服务返回的错误码
* - 临时测试
*
* @param code 状态码
* @param message 错误信息
* @return Result对象
*/
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}

/**
* 失败响应,带错误数据
*
* 适用场景:
* - 参数校验失败时,返回字段级的错误信息
*
* 响应示例:
* {
* "code": 4000,
* "message": "参数校验失败",
* "data": {
* "username": "用户名长度需在4-20字符之间",
* "email": "邮箱格式不正确"
* },
* "timestamp": 1734422400000
* }
*
* @param resultCode 状态码枚举
* @param data 错误数据(通常是 Map 结构)
* @return Result对象
*/
public static <T> Result<T> error(ResultCode resultCode, T data) {
return new Result<>(
resultCode.getCode(),
resultCode.getMessage(),
data
);
}

/**
* 失败响应,自定义消息 + 错误数据
*
* @param resultCode 状态码枚举
* @param customMessage 自定义错误信息
* @param data 错误数据
* @return Result对象
*/
public static <T> Result<T> error(ResultCode resultCode, String customMessage, T data) {
return new Result<>(
resultCode.getCode(),
customMessage,
data
);
}
}

设计细节解析

  1. @JsonInclude(JsonInclude.Include.NON_NULL) 的作用

    未添加此注解时的响应:

    1
    2
    3
    4
    5
    6
    {
    "code": 200,
    "message": "删除成功",
    "data": null,
    "timestamp": 1734422400000
    }

    添加此注解后的响应:

    1
    2
    3
    4
    5
    {
    "code": 200,
    "message": "删除成功",
    "timestamp": 1734422400000
    }

    优势

    • 减少响应体大小(虽然只省略了几个字节,但在高并发场景下累积效果明显)
    • JSON 更简洁,前端解析时不需要判断 data !== null
  2. 为什么提供多个重载的 successerror 方法?

    这是为了适配不同的业务场景,避免开发者手动拼接字符串或创建 Map。

    场景对比

    场景使用方法响应示例
    删除用户Result.success(){ "code": 200, "message": "操作成功" }
    查询用户Result.success(user){ "code": 200, "message": "操作成功", "data": {...} }
    发送邮件Result.success("邮件发送成功"){ "code": 200, "message": "邮件发送成功" }
    用户不存在Result.error(ResultCode.USER_NOT_EXIST){ "code": 5001, "message": "用户不存在" }
    参数校验失败Result.error(ResultCode.PARAM_VALID_ERROR, errorMap){ "code": 4000, "message": "参数校验失败", "data": {...} }
  3. timestamp 字段的实战价值

    在生产环境中,前端可能会反馈"刚才 10 点 15 分左右报错了"。有了 timestamp,我们可以:

    • 快速定位到对应时间段的日志
    • 判断是前端缓存的旧数据还是真实的新请求
    • 在分布式系统中关联不同服务的日志

14.2.3. 使用示例:Controller 中的最佳实践

现在我们已经有了 ResultCodeResult<T>,让我们看看如何在 Controller 中优雅地使用它们。

文件路径src/main/java/com/example/demo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.example.demo.controller;

import com.example.demo.common.Result;
import com.example.demo.dto.UserDTO;
import com.example.demo.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

/**
* 查询用户列表
* 返回类型:Result<List<UserDTO>>
*/
@GetMapping
public Result<List<UserDTO>> listUsers() {
List<UserDTO> users = userService.listAll();
return Result.success(users);
}

/**
* 根据ID查询用户
* 返回类型:Result<UserDTO>
*/
@GetMapping("/{id}")
public Result<UserDTO> getUser(@PathVariable Long id) {
// Service 层如果找不到用户,会抛出 BusinessException
// 异常会被全局异常处理器捕获,自动转换为错误响应
UserDTO user = userService.getById(id);
return Result.success(user);
}

/**
* 新增用户
* 返回类型:Result<Long>(返回新增用户的ID)
*/
@PostMapping
public Result<Long> createUser(@RequestBody @Valid UserDTO dto) {
Long userId = userService.create(dto);
return Result.success(userId);
}

/**
* 更新用户
* 返回类型:Result<Void>(无返回数据)
*/
@PutMapping("/{id}")
public Result<Void> updateUser(
@PathVariable Long id,
@RequestBody @Valid UserDTO dto
) {
userService.update(id, dto);
return Result.success();
}

/**
* 删除用户
* 返回类型:Result<Void>
*/
@DeleteMapping("/{id}")
public Result<Void> deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return Result.success("用户删除成功");
}
}

关键要点

  1. Controller 层非常简洁,只负责调用 Service 和返回 Result
  2. 所有异常都由 Service 层抛出,Controller 层不需要 try-catch
  3. 返回类型始终是 Result<T>,保证响应格式统一

14.3. 异常工程学:构建分层异常体系

14.3.1. Java 异常体系回顾

在设计自定义异常之前,我们需要先理解 Java 的异常体系结构。

mermaid-diagram (4)

核心分类

类型是否必须捕获典型场景事务回滚
Error虚拟机错误(OutOfMemoryError)
一般无法恢复
✅ 自动回滚
受检异常
(Checked Exception)

必须 try-catch 或 throws
文件不存在(FileNotFoundException)
网络超时(SocketTimeoutException)
❌ 默认不回滚
需配置 rollbackFor
非受检异常
(Unchecked Exception)

可选择性捕获
空指针(NullPointerException)
数组越界(ArrayIndexOutOfBoundsException)
业务异常(BusinessException)
✅ 自动回滚

14.3.2. 为什么自定义业务异常必须继承 RuntimeException?

这是一个非常重要但容易被忽视的设计决策。让我们通过对比来理解。

错误做法:继承 Exception(受检异常)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 定义一个受检异常
public class BusinessException extends Exception {
public BusinessException(String message) {
super(message);
}
}

// Service 层代码
public void register(UserDTO dto) throws BusinessException { // 必须声明 throws
if (existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已存在");
}
save(dto);
}

// Controller 层代码
@PostMapping("/register")
public Result<Void> register(@RequestBody @Valid UserDTO dto) {
try {
userService.register(dto); // 必须 try-catch
return Result.success();
} catch (BusinessException e) {
return Result.error(5002, e.getMessage());
}
}

问题分析

代码臃肿

  • Service 层的每个方法都要声明 throws BusinessException
  • Controller 层被迫到处写 try-catch
  • 违反了"关注点分离"原则

事务回滚失效

1
2
3
4
5
6
7
@Transactional
public void register(UserDTO dto) throws BusinessException {
userMapper.insert(dto); // 插入数据
if (condition) {
throw new BusinessException("xxx");
}
}

问题:Spring 的 @Transactional 默认只在遇到 RuntimeExceptionError 时回滚。这里抛出的是受检异常,事务会提交,导致脏数据。

解决方法(但很麻烦):

1
@Transactional(rollbackFor = BusinessException.class)

代码传播性差

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A 方法
public void methodA() throws BusinessException {
methodB();
}

// B 方法
public void methodB() throws BusinessException {
methodC();
}

// C 方法抛出异常
public void methodC() throws BusinessException {
throw new BusinessException("错误");
}

每一层都要声明 throws,修改一个方法可能需要改整个调用链。

正确做法:继承 RuntimeException(非受检异常)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 定义一个非受检异常
public class BusinessException extends RuntimeException {
private final int code;

public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}
}

// Service 层代码(无需 throws)
public void register(UserDTO dto) {
if (existsByUsername(dto.getUsername())) {
throw new BusinessException(ResultCode.USER_ALREADY_EXIST);
}
save(dto);
}

// Controller 层代码(无需 try-catch,交给全局异常处理器)
@PostMapping("/register")
public Result<Void> register(@RequestBody @Valid UserDTO dto) {
userService.register(dto);
return Result.success();
}

优势分析

代码简洁

  • 不需要到处声明 throws
  • Controller 层完全不需要 try-catch

事务自动回滚

1
2
3
4
5
6
7
8
@Transactional  // 无需额外配置
public void register(UserDTO dto) {
userMapper.insert(dto);
if (condition) {
throw new BusinessException(ResultCode.USER_ALREADY_EXIST);
}
// 抛出异常后,上面的 insert 会自动回滚
}

符合业务语义

  • 业务异常(如"用户不存在")本质上是一种业务流程分支,不是技术故障
  • 不应该强制捕获,而应该让它自然向上传播到全局异常处理器

14.3.3. 设计自定义业务异常

设计目标

  1. 携带业务状态码
  2. 携带错误信息
  3. 支持枚举和自定义消息两种构造方式
  4. 继承 RuntimeException

文件路径src/main/java/com/example/demo/exception/BusinessException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package com.example.demo.exception;

import com.example.demo.common.ResultCode;
import lombok.Getter;

/**
* 自定义业务异常
*
* 设计原则:
* 1. 继承 RuntimeException,不需要强制捕获
* 2. 携带业务状态码,方便全局异常处理器识别
* 3. 用于表示"预期内的业务逻辑错误",区别于系统故障
*
* 使用场景:
* - 用户不存在
* - 用户名已存在
* - 库存不足
* - 余额不足
* - 权限不足
* 等所有可以预见的业务规则校验失败的情况
*/
@Getter
public class BusinessException extends RuntimeException {

/**
* 业务状态码
*/
private final int code;

/**
* 构造器1:使用枚举(推荐)
*
* 示例:
* throw new BusinessException(ResultCode.USER_NOT_EXIST);
*
* @param resultCode 状态码枚举
*/
public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}

/**
* 构造器2:使用枚举 + 自定义消息
*
* 适用场景:需要在枚举定义的基础上,提供更详细的错误信息
*
* 示例:
* throw new BusinessException(
* ResultCode.PARAM_VALID_ERROR,
* "用户名长度必须在4-20字符之间"
* );
*
* @param resultCode 状态码枚举
* @param message 自定义错误信息
*/
public BusinessException(ResultCode resultCode, String message) {
super(message);
this.code = resultCode.getCode();
}

/**
* 构造器3:完全自定义(谨慎使用)
*
* 注意:此构造器应该尽量避免使用,优先使用枚举定义的状态码
*
* @param code 状态码
* @param message 错误信息
*/
public BusinessException(int code, String message) {
super(message);
this.code = code;
}

/**
* 构造器4:包装原始异常(用于异常转换)
*
* 适用场景:
* - 调用第三方服务时,将第三方异常转换为我方业务异常
*
* 示例:
* try {
* wxApiClient.pay(order);
* } catch (WxApiException e) {
* throw new BusinessException(
* ResultCode.THIRD_PARTY_SERVICE_ERROR,
* "支付服务暂时不可用",
* e
* );
* }
*
* @param resultCode 状态码枚举
* @param message 错误信息
* @param cause 原始异常
*/
public BusinessException(ResultCode resultCode, String message, Throwable cause) {
super(message, cause);
this.code = resultCode.getCode();
}
}

14.3.4. 业务异常在 Service 层的应用

让我们通过几个真实的业务场景来理解如何使用 BusinessException

场景一:查询不存在的资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class UserService {

@Autowired
private UserMapper userMapper;

public UserDTO getById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
// 直接抛出业务异常,无需返回 null 或 Optional
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
return convertToDTO(user);
}
}

对比传统做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 传统做法1:返回 null
public UserDTO getById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return null; // Controller 层需要判断 null
}
return convertToDTO(user);
}

// 传统做法2:返回 Optional
public Optional<UserDTO> getById(Long id) {
User user = userMapper.selectById(id);
return Optional.ofNullable(user)
.map(this::convertToDTO); // Controller 层需要判断 isPresent
}

// 传统做法3:返回 Result
public Result<UserDTO> getById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
return Result.error(ResultCode.USER_NOT_EXIST); // Service 层返回 Result,职责混乱
}
return Result.success(convertToDTO(user));
}

使用异常的优势

  • Service 层的方法签名更简洁,只返回业务数据类型
  • Controller 层不需要判断 null 或 isPresent
  • 符合"快速失败"原则,问题立即暴露

场景二:业务规则校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void register(UserDTO dto) {
// 校验用户名是否已存在
if (userMapper.existsByUsername(dto.getUsername())) {
throw new BusinessException(ResultCode.USER_ALREADY_EXIST);
}

// 校验邮箱是否已被注册
if (userMapper.existsByEmail(dto.getEmail())) {
throw new BusinessException(
ResultCode.USER_ALREADY_EXIST,
"该邮箱已被注册"
);
}

// 如果走到这里,说明校验都通过了
User user = convertToEntity(dto);
userMapper.insert(user);
}

场景三:事务回滚验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 扣减转出方余额
Account fromAccount = accountMapper.selectById(fromId);
if (fromAccount.getBalance().compareTo(amount) < 0) {
// 抛出异常后,事务自动回滚,上面的所有数据库操作都会撤销
throw new BusinessException(ResultCode.BALANCE_NOT_ENOUGH);
}
fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
accountMapper.updateById(fromAccount);

// 2. 增加转入方余额
Account toAccount = accountMapper.selectById(toId);
toAccount.setBalance(toAccount.getBalance().add(amount));
accountMapper.updateById(toAccount);

// 如果这里抛出任何 RuntimeException,上面的两次 update 都会回滚
}

场景四:第三方服务异常转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void sendSms(String mobile, String code) {
try {
// 调用第三方短信服务
smsClient.send(mobile, code);
} catch (SmsClientException e) {
// 将第三方异常转换为我方业务异常
// 这样全局异常处理器可以统一处理
log.error("短信发送失败, mobile: {}", mobile, e);
throw new BusinessException(
ResultCode.THIRD_PARTY_SERVICE_ERROR,
"短信发送失败,请稍后重试",
e // 保留原始异常,方便排查问题
);
}
}

14.4. 核心实战:全局异常处理器的完整实现

现在我们已经有了统一的响应体和自定义异常,接下来要解决的问题是:如何将异常自动转换为统一的 Result 格式返回给前端?

答案是:全局异常处理器

14.4.1. Spring MVC 异常处理机制剖析

在深入代码之前,我们先理解 Spring MVC 处理请求的完整流程。

Spring_DispatcherServlet-2025-12-17-024157

核心组件

  1. DispatcherServlet:前端控制器,所有请求的入口
  2. HandlerExceptionResolver:异常解析器接口
  3. @RestControllerAdvice:全局异常处理注解,是 @ControllerAdvice + @ResponseBody 的组合
  4. @ExceptionHandler:标注在方法上,指定要处理的异常类型

14.4.2. @RestControllerAdvice 的工作原理

@RestControllerAdvice 是一个 AOP 切面,它会拦截所有 Controller 抛出的异常。

注解解析

1
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
  • @ControllerAdvice:标记这是一个增强类,作用于所有 @Controller@RestController
  • @ResponseBody:方法返回值会自动序列化为 JSON

作用域

1
2
3
4
5
6
7
8
9
10
11
// 默认作用于所有 Controller
@RestControllerAdvice
public class GlobalExceptionHandler { }

// 指定作用于某个包下的 Controller
@RestControllerAdvice(basePackages = "com.example.demo.controller")
public class GlobalExceptionHandler { }

// 指定作用于某些注解标注的类
@RestControllerAdvice(annotations = RestController.class)
public class GlobalExceptionHandler { }

14.4.3. 完整的全局异常处理器实现

我们将异常分为以下几类,分别处理:

异常类型触发场景处理策略
MethodArgumentNotValidException@Valid@Validated 校验失败提取字段错误信息,返回字段级错误
ConstraintViolationExceptionURL 参数(@PathVariable@RequestParam)校验失败提取错误信息
BusinessExceptionService 层主动抛出的业务异常提取 code 和 message,直接返回
NoHandlerFoundException请求的路径不存在(404)返回友好提示
HttpRequestMethodNotSupportedException请求方法不支持(如 GET 请求了 POST 接口)返回支持的方法列表
HttpMediaTypeNotSupportedExceptionContent-Type 不支持返回支持的媒体类型
Exception所有未被上述异常捕获的情况记录详细日志,返回模糊提示

文件路径src/main/java/com/example/demo/handler/GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
package com.example.demo.handler;

import com.example.demo.common.Result;
import com.example.demo.common.ResultCode;
import com.example.demo.exception.BusinessException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
* 全局异常处理器
*
* 作用:
* 1. 统一捕获所有 Controller 抛出的异常
* 2. 将异常转换为标准的 Result 格式
* 3. 记录异常日志,方便排查问题
* 4. 向前端返回友好的错误提示,避免暴露技术细节
*
* 注意事项:
* 1. @ExceptionHandler 的处理顺序:从具体到抽象(子类异常优先于父类异常)
* 2. 多个 Handler 可以处理同一个异常,但只有最具体的那个会生效
* 3. 日志级别:业务异常用 WARN,系统异常用 ERROR
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

// ==================== 1. 参数校验异常 (JSR-303) ====================

/**
* 处理 @RequestBody 参数校验失败抛出的异常
*
* 触发场景:
* - Controller 方法参数前有 @Valid@Validated 注解
* - 请求体中的字段不满足 DTO 上定义的校验规则
*
* 示例:
* @PostMapping("/users")
* public Result<Void> create(@RequestBody @Validated UserDTO dto) { }
*
* 如果 dto 中的 username 为空,会抛出此异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // HTTP 400
public Result<Map<String, String>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e
) {
BindingResult bindingResult = e.getBindingResult();

// 提取所有字段错误信息,构建成 Map
// 格式:{ "fieldName": "errorMessage" }
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
String fieldName = fieldError.getField();
String errorMessage = fieldError.getDefaultMessage();
errors.put(fieldName, errorMessage);
}

// WARN 级别日志:这是客户端传参问题,不是服务器故障
log.warn("请求体参数校验失败, URI: {}, 错误详情: {}",
getCurrentRequestURI(), errors);

// 返回字段级的错误信息
return Result.error(ResultCode.PARAM_VALID_ERROR, errors);
}

/**
* 处理 @PathVariable@RequestParam 参数校验失败抛出的异常
*
* 触发场景:
* - Controller 类上有 @Validated 注解
* - @PathVariable@RequestParam 参数前有校验注解(如 @Min, @NotBlank
*
* 示例:
* @RestController
* @RequestMapping("/users")
* @Validated
* public class UserController {
* @GetMapping("/{id}")
* public Result<UserDTO> getUser(@PathVariable @Min(1) Long id) { }
* }
*
* 如果 id = 0,会抛出此异常
*/
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // HTTP 400
public Result<Void> handleConstraintViolationException(
ConstraintViolationException e
) {
// 提取所有违反约束的错误信息
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
String errorMsg = violations.stream()
.map(violation -> violation.getMessage())
.collect(Collectors.joining("; "));

log.warn("URL参数校验失败, URI: {}, 错误信息: {}",
getCurrentRequestURI(), errorMsg);

return Result.error(ResultCode.PARAM_VALID_ERROR, errorMsg);
}

// ==================== 2. 业务逻辑异常 ====================

/**
* 处理 Service 层主动抛出的 BusinessException
*
* 触发场景:
* - Service 层检测到业务规则不满足,主动抛出异常
*
* 示例:
* if (user == null) {
* throw new BusinessException(ResultCode.USER_NOT_EXIST);
* }
*
* 特点:
* - 这是"预期内"的异常,是业务流程的一部分
* - HTTP 状态码返回 200(通信成功)
* - 业务状态码返回具体的错误码(如 5001)
*/
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.OK) // HTTP 200(业务层面失败,但通信成功)
public Result<Void> handleBusinessException(BusinessException e) {
// WARN 级别日志:记录业务阻断情况,便于统计分析
log.warn("业务异常, code: {}, message: {}, URI: {}",
e.getCode(), e.getMessage(), getCurrentRequestURI());

// 直接返回异常中携带的状态码和消息
return Result.error(e.getCode(), e.getMessage());
}

// ==================== 3. 404 路径不存在异常 ====================

/**
* 处理请求路径不存在的异常
*
* 触发场景:
* - 客户端请求的路径没有对应的 Controller 映射
*
* 前置条件(必须配置):
* spring:
* mvc:
* throw-exception-if-no-handler-found: true
* web:
* resources:
* add-mappings: false
*
* 如果没有上述配置,404 不会抛出异常,而是返回默认错误页
*/
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND) // HTTP 404
public Result<Void> handleNotFoundException(NoHandlerFoundException e) {
log.warn("请求路径不存在, Method: {}, URI: {}",
e.getHttpMethod(), e.getRequestURL());

String message = String.format(
"请求路径 [%s %s] 不存在",
e.getHttpMethod(),
e.getRequestURL()
);

return Result.error(ResultCode.NOT_FOUND, message);
}

// ==================== 4. 请求方法不支持异常 ====================

/**
* 处理 HTTP 请求方法不支持的异常
*
* 触发场景:
* - 接口定义为 POST,但客户端发送了 GET 请求
*
* 示例:
* @PostMapping("/users") // 只支持 POST
* public Result<Void> create(@RequestBody UserDTO dto) { }
*
* 如果客户端发送 GET /users,会抛出此异常
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) // HTTP 405
public Result<Void> handleMethodNotSupportedException(
HttpRequestMethodNotSupportedException e
) {
log.warn("请求方法不支持, Method: {}, URI: {}, 支持的方法: {}",
e.getMethod(), getCurrentRequestURI(), e.getSupportedHttpMethods());

String message = String.format(
"不支持 %s 请求,请使用 %s",
e.getMethod(),
e.getSupportedHttpMethods()
);

return Result.error(ResultCode.METHOD_NOT_SUPPORTED, message);
}

// ==================== 5. Content-Type 不支持异常 ====================

/**
* 处理 Content-Type 不支持的异常
*
* 触发场景:
* - 接口要求 application/json,但客户端发送了 application/x-www-form-urlencoded
*
* 示例:
* @PostMapping(value = "/users", consumes = "application/json")
* public Result<Void> create(@RequestBody UserDTO dto) { }
*
* 如果客户端的 Content-Type 不是 application/json,会抛出此异常
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) // HTTP 415
public Result<Void> handleMediaTypeNotSupportedException(
HttpMediaTypeNotSupportedException e
) {
log.warn("Content-Type 不支持, ContentType: {}, URI: {}, 支持的类型: {}",
e.getContentType(), getCurrentRequestURI(), e.getSupportedMediaTypes());

String message = String.format(
"不支持 %s 格式,请使用 %s",
e.getContentType(),
e.getSupportedMediaTypes()
);

return Result.error(ResultCode.MEDIA_TYPE_NOT_SUPPORTED, message);
}

// ==================== 6. 系统兜底异常(最重要)====================

/**
* 处理所有未被上述 Handler 捕获的异常
*
* 触发场景:
* - 空指针异常(NullPointerException)
* - 数据库异常(SQLException)
* - 第三方服务调用异常
* - 其他所有未预期的 RuntimeException
*
* 注意事项:
* 1. 这是最后的兜底Handler,必须放在最后
* 2. 必须打印完整的异常堆栈,这是排查 Bug 的唯一线索
* 3. 绝对不能向前端返回 e.getMessage(),避免暴露技术细节
* 4. 使用 ERROR 级别日志,触发监控告警
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // HTTP 500
public Result<Void> handleSystemException(Exception e) {
// ERROR 级别日志:这是系统故障,需要立即修复
// 打印完整堆栈,包括异常链
log.error("系统发生未处理异常, URI: {}, 异常类型: {}",
getCurrentRequestURI(), e.getClass().getName(), e);

// 安全红线:绝不向前端返回技术细节
// 原因:
// 1. 用户看不懂技术术语,体验差
// 2. 可能暴露表结构、SQL语句等敏感信息,给攻击者提供线索
return Result.error(ResultCode.FAILURE);
}

// ==================== 辅助方法 ====================

/**
* 获取当前请求的 URI
* 用于日志记录
*/
private String getCurrentRequestURI() {
try {
HttpServletRequest request =
((org.springframework.web.context.request.ServletRequestAttributes)
org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes())
.getRequest();
return request.getRequestURI();
} catch (Exception e) {
return "Unknown";
}
}
}

代码要点解析

  1. 异常处理顺序

    Spring 会按照"从具体到抽象"的顺序匹配异常处理器:

    1
    2
    3
    4
    5
    BusinessException

    RuntimeException

    Exception

    如果抛出的是 BusinessException,会优先匹配 handleBusinessException,而不是 handleSystemException

  2. 为什么业务异常返回 HTTP 200?

    1
    2
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.OK) // HTTP 200

    因为"用户不存在"、"库存不足"等业务异常,本质上是业务流程的一部分,不是通信错误。HTTP 200 表示服务器成功处理了请求,业务结果通过 JSON 中的 code 字段来判断。

  3. 日志级别的选择

    异常类型日志级别原因
    参数校验失败WARN客户端传参错误,不是服务器故障
    业务异常WARN预期内的业务流程分支
    系统异常ERROR非预期的故障,需要立即修复
  4. 为什么不能返回 e.getMessage()

    假设数据库连接失败,异常信息可能是:

    1
    2
    3
    4
    com.mysql.cj.jdbc.exceptions.CommunicationsException: 
    Communications link failure. The last packet sent successfully to the
    server was 0 milliseconds ago. The driver has not received any packets
    from the server.

    如果直接返回给前端:

    • 用户看不懂技术术语
    • 暴露了数据库类型(MySQL)
    • 可能被攻击者利用

    正确做法:

    • 后端日志记录详细信息
    • 前端只收到"系统繁忙,请稍后重试"

14.4.4. 配置 404 异常的统一处理

在 Spring Boot 中,当一个请求无法匹配到任何 Controller 时,默认行为是转发到 /error 路径,而不是直接抛出 NoHandlerFoundException 异常。为了能像处理其他业务异常一样,通过全局异常处理器(@RestControllerAdvice)来捕获 404 错误并返回统一的 JSON 格式,我们需要进行一些配置。

方案一:抛出异常(旧版方式,已不推荐)

在较早的 Spring Boot 版本中,可以通过以下配置强制应用抛出 NoHandlerFoundException

文件路径src/main/resources/application.yml

以下配置在 Spring Boot 2.6 及以后版本中已被弃用,在 3.x 版本中可能不再有效。

1
2
3
4
5
6
spring:
mvc:
throw-exception-if-no-handler-found: true # 当找不到 Handler 时,抛出 NoHandlerFoundException
web:
resources:
add-mappings: false # 禁用默认的静态资源映射

版本变更与注意事项

  • 配置已弃用spring.mvc.throw-exception-if-no-handler-found 属性自 Spring Boot 2.6 起已被标记为废弃 (Deprecated)。这意味着它在未来的版本中将被移除,不应再继续使用。
  • 功能限制:此方法需要关闭 Spring Boot 的默认静态资源映射 (spring.web.resources.add-mappings: false)。这会导致 src/main/resources/static 目录下的所有文件都无法通过应用直接访问。

适用场景

  • ✅ 仅适用于老旧项目维护。
  • ❌ 不适用于新项目,或需要同时提供 API 和静态资源(如文档、后台管理页面)的项目。

方案二:自定义 ErrorController(当前推荐的最佳实践)

为了解决旧方案的限制并适应 Spring Boot 的发展,目前推荐通过实现 ErrorController 接口来统一处理所有 HTTP 错误,包括 404。这种方式无需修改任何 application.yml 配置,并且可以完美兼容静态资源服务。

核心思想:利用 Spring Boot 内置的错误处理机制。当发生任何无法处理的错误(包括 404)时,Spring Boot 会将请求转发到 /error 路径。我们只需编写一个 Controller 来接管这个路径的处理权即可。

实现步骤

创建一个自定义的 ErrorController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@RestController
public class GlobalErrorController implements ErrorController {

/**
* 接管 /error 路径,处理所有 Spring Boot 转发过来的错误。
*/
@RequestMapping("/error")
public Result<Void> handleError(HttpServletRequest request) {
// 从 request 中获取 HTTP 状态码
Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");

// 专门处理 404 Not Found 错误
if (statusCode != null && statusCode == HttpStatus.NOT_FOUND.value()) {
return Result.error(ResultCode.NOT_FOUND, "您访问的资源不存在");
}

// 也可以根据其他状态码返回不同的错误信息
// if (statusCode == HttpStatus.INTERNAL_SERVER_ERROR.value()) { ... }

// 对于其他所有未特定处理的错误,返回一个通用的失败响应
return Result.error(ResultCode.FAILURE, "服务器发生未知错误");
}
}

这种方式的优势

  • 无需弃用配置:完全不依赖已废弃的 application.yml 属性。
  • 兼容静态资源:Spring Boot 的静态资源服务可以保持开启,不影响正常功能。
  • 统一处理入口:不仅是 404,像 500、403、401 等所有 HTTP 错误最终都会汇集到这个 Controller,便于实现统一、规范的错误响应格式。
  • 适用所有项目:无论是前后端分离还是单体应用,此方案都表现良好。

14.5. 字段级错误反馈:让前端精准定位问题

在上一章,我们学习了 JSR-303 参数校验。当校验失败时,Spring 会抛出 MethodArgumentNotValidException。现在我们已经有了全局异常处理器,可以捕获这个异常并转换为统一的响应格式。

但我们还可以做得更好:返回字段级的错误信息,让前端能够精确地将错误提示显示在对应的输入框下方。

14.5.1. 问题场景

假设我们有这样一个 DTO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
public class UserRegisterDTO {

@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间")
private String username;

@NotBlank(message = "密码不能为空")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$",
message = "密码必须8-16位,且包含大小写字母和数字"
)
private String password;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
}

当客户端发送以下请求时:

1
2
3
4
5
6
POST /users/register
{
"username": "ab",
"password": "123",
"email": "invalid"
}

传统做法(拼接所有错误信息):

1
2
3
4
5
{
"code": 4000,
"message": "用户名长度需在4-20字符之间; 密码必须8-16位,且包含大小写字母和数字; 邮箱格式不正确",
"timestamp": 1734422400000
}

问题

  • 前端只能显示一个通用的错误提示
  • 无法精确定位到具体的输入框
  • 用户体验差

理想做法(字段级错误):

1
2
3
4
5
6
7
8
9
10
{
"code": 4000,
"message": "参数校验失败",
"data": {
"username": "用户名长度需在4-20字符之间",
"password": "密码必须8-16位,且包含大小写字母和数字",
"email": "邮箱格式不正确"
},
"timestamp": 1734422400000
}

优势

  • 前端可以遍历 data 对象,将每个字段的错误提示显示在对应的输入框下方
  • 用户能清楚知道每个字段的具体问题
  • 体验极佳

14.5.2. 实现字段级错误反馈

我们已经在全局异常处理器中实现了这个功能,让我们再次回顾关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Map<String, String>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e
) {
BindingResult bindingResult = e.getBindingResult();

// 关键:构建字段名 -> 错误信息的 Map
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
String fieldName = fieldError.getField(); // "username"
String errorMessage = fieldError.getDefaultMessage(); // "用户名长度需在4-20字符之间"
errors.put(fieldName, errorMessage);
}

log.warn("请求体参数校验失败, URI: {}, 错误详情: {}",
getCurrentRequestURI(), errors);

// 将错误 Map 作为 data 返回
return Result.error(ResultCode.PARAM_VALID_ERROR, errors);
}

代码解析

  1. BindingResult 的作用

    • MethodArgumentNotValidException 内部包含一个 BindingResult 对象
    • BindingResult 存储了所有字段的校验结果
  2. FieldError 的结构

    1
    2
    3
    4
    5
    FieldError fieldError = ...
    fieldError.getField(); // 字段名:username
    fieldError.getDefaultMessage(); // 错误信息:用户名长度需在4-20字符之间
    fieldError.getRejectedValue(); // 被拒绝的值:ab
    fieldError.getCode(); // 错误码:Size
  3. 构建 Map 的优势

    • Map 的 key 是字段名,前端可以直接定位
    • Map 的 value 是错误信息,可以直接显示

显示效果

1
2
3
4
5
6
7
8
用户名: [ab_____]
↑ 用户名长度需在4-20字符之间(红色提示)

密码: [123____]
↑ 密码必须8-16位,且包含大小写字母和数字(红色提示)

邮箱: [invalid]
↑ 邮箱格式不正确(红色提示)

14.5.3. 处理嵌套对象的校验错误

如果 DTO 中包含嵌套对象,错误信息的字段名会带有路径。

示例 DTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
public class OrderDTO {

@NotNull
private Long productId;

@Valid // 嵌套对象必须加 @Valid
@NotNull
private AddressDTO address;
}

@Data
public class AddressDTO {

@NotBlank(message = "收货人姓名不能为空")
private String receiverName;

@NotBlank(message = "收货人电话不能为空")
private String receiverPhone;
}

校验失败时的错误 Map

1
2
3
4
5
6
7
8
9
{
"code": 4000,
"message": "参数校验失败",
"data": {
"productId": "不能为null",
"address.receiverName": "收货人姓名不能为空",
"address.receiverPhone": "收货人电话不能为空"
}
}

前端处理

1
2
3
4
5
6
7
8
// 错误信息:{ "address.receiverName": "收货人姓名不能为空" }

// 可以用点语法访问
const error = errors['address.receiverName']

// 或者按路径拆分
const [parent, field] = 'address.receiverName'.split('.')
// parent = "address", field = "receiverName"