统一异常处理与参数校验
异常处理概述
传统 try-catch 的问题
在未引入统一异常处理的项目中,异常处理通常散落在各个业务方法中:
@RestController
public class UserController {
@GetMapping("/users/{id}")
public Result<User> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
if (user == null) {
return Result.error(404, "用户不存在");
}
return Result.success(user);
} catch (DataAccessException e) {
log.error("数据库查询异常", e);
return Result.error(500, "系统内部错误");
} catch (Exception e) {
log.error("未知异常", e);
return Result.error(500, "系统繁忙,请稍后重试");
}
}
@PostMapping("/users")
public Result<User> createUser(@RequestBody User user) {
try {
// 参数手动校验
if (StringUtils.isBlank(user.getName())) {
return Result.error(400, "用户名不能为空");
}
if (user.getAge() < 18 || user.getAge() > 120) {
return Result.error(400, "年龄必须在 18-120 之间");
}
User saved = userService.create(user);
return Result.success(saved);
} catch (Exception e) {
log.error("创建用户异常", e);
return Result.error(500, "系统繁忙,请稍后重试");
}
}
}上述代码暴露出的问题:
| 问题 | 表现 |
|---|---|
| 代码重复 | 每个方法都包含 try-catch 模板和错误码处理,样板代码占比 40% 以上 |
| 错误处理不一致 | 不同开发人员在不同 Controller 中对同类异常返回的状态码、消息格式不一致 |
| 异常吞噬 | catch 后仅返回错误信息,原始异常堆栈可能丢失 |
| 校验与业务耦合 | 参数校验写在业务方法内,不满足关注点分离原则 |
| 可维护性差 | 新增异常类型需要修改所有 Controller |
全局统一异常处理思路
统一异常处理的核心目标是:业务代码只关注正常流程,异常处理由统一拦截层负责。
请求 → Filter → Interceptor → ControllerAdvice
↓
Controller(业务方法)
↓
Service(抛出业务异常)
↓
ControllerAdvice(拦截异常)
↓
统一响应体返回客户端- AOP 思想:通过切面拦截所有 Controller 请求,对异常集中处理
- 约定优于配置:定义异常处理规范,团队统一遵循
- 关注点分离:Controller/Service 只需抛出业务异常,不需要关心如何序列化错误响应
Spring Boot 全局异常处理
@RestControllerAdvice + @ExceptionHandler
Spring 3.2 引入 @ControllerAdvice(Spring 4.3 引入 @RestControllerAdvice 等价于 @ControllerAdvice + @ResponseBody),用于定义全局异常处理器。
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/** 参数校验异常 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
/** 业务异常 */
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBiz(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ApiResponse.error(e.getCode(), e.getMessage());
}
/** 参数类型转换异常 */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), "请求体格式错误");
}
/** 缺少请求参数 */
@ExceptionHandler(MissingServletRequestParameterException.class)
public ApiResponse<Void> handleMissingParam(MissingServletRequestParameterException e) {
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(),
"缺少必要参数: " + e.getParameterName());
}
/** 请求方法不支持 */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ApiResponse<Void> handleMethodNotSupport(HttpRequestMethodNotSupportedException e) {
return ApiResponse.error(405, "请求方法不支持: " + e.getMethod());
}
/** 404 资源未找到 */
@ExceptionHandler(NoHandlerFoundException.class)
public ApiResponse<Void> handleNotFound(NoHandlerFoundException e) {
return ApiResponse.error(404, "请求的资源不存在");
}
/** 未捕获的顶级异常 */
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleUnknown(Exception e) {
log.error("系统未知异常", e);
return ApiResponse.error(ErrorCode.SYSTEM_ERROR.getCode(), "系统繁忙,请稍后重试");
}
}处理优先级:Spring 在匹配异常处理器时,按照最精确匹配原则——子类异常优先匹配子类的 @ExceptionHandler,若无则匹配父类处理器。
自定义异常基类
引入自定义业务异常,使业务代码能够通过异常携带错误码和错误信息。
/**
* 业务异常基类
* 继承 RuntimeException,不强制调用方捕获
*/
@Getter
public class BizException extends RuntimeException {
private final int code;
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public BizException(ErrorCode errorCode, Object... args) {
super(String.format(errorCode.getMessage(), args));
this.code = errorCode.getCode();
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}/**
* 带错误码和错误数据的业务异常
* 适用于需要返回额外错误字段的场景(如字段级校验结果)
*/
@Getter
public class ServiceException extends BizException {
private final transient Object data;
public ServiceException(int code, String message) {
super(code, message);
this.data = null;
}
public ServiceException(int code, String message, Object data) {
super(code, message);
this.data = data;
}
}为何继承 RuntimeException 而不是 Exception:
| 方面 | RuntimeException | Exception |
|---|---|---|
| 是否强制 catch | 否,调用方可选 | 是,编译器强制 |
| 使用场景 | 多数业务异常、参数错误 | 需要调用方显式处理的异常 |
| AOP 拦截 | 全局异常处理器统一处理 | 需要手动 try-catch |
统一响应体
@Data
@Accessors(chain = true)
@ApiModel(description = "统一响应体")
public class ApiResponse<T> {
@ApiModelProperty(value = "状态码", example = "200")
private int code;
@ApiModelProperty(value = "响应消息", example = "操作成功")
private String message;
@ApiModelProperty(value = "响应数据")
private T data;
@ApiModelProperty(value = "时间戳", example = "2026-07-05T10:30:00")
private LocalDateTime timestamp;
private ApiResponse() {
this.timestamp = LocalDateTime.now();
}
public static <T> ApiResponse<T> success() {
return new ApiResponse<T>()
.setCode(ErrorCode.SUCCESS.getCode())
.setMessage(ErrorCode.SUCCESS.getMessage());
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<T>()
.setCode(ErrorCode.SUCCESS.getCode())
.setMessage(ErrorCode.SUCCESS.getMessage())
.setData(data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<T>()
.setCode(ErrorCode.SUCCESS.getCode())
.setMessage(message)
.setData(data);
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<T>()
.setCode(code)
.setMessage(message);
}
public static <T> ApiResponse<T> error(int code, String message, T data) {
return new ApiResponse<T>()
.setCode(code)
.setMessage(message)
.setData(data);
}
public static <T> ApiResponse<T> error(ErrorCode errorCode) {
return new ApiResponse<T>()
.setCode(errorCode.getCode())
.setMessage(errorCode.getMessage());
}
}错误码设计
错误码采用数字分段设计,便于快速定位问题范围和按业务归类。
1xx — 成功类(保留)
2xx — 通用成功/客户端错误
200 — 成功
400 — 请求参数错误
401 — 未认证
403 — 无权限
404 — 资源不存在
405 — 请求方法不支持
415 — 不支持的媒体类型
429 — 请求过于频繁
5xx — 服务端错误
500 — 系统内部错误
502 — 上游服务不可用
503 — 服务暂不可用(熔断/限流)
6xxx — 业务错误码
6001 — 用户不存在
6002 — 用户名已存在
6003 — 密码错误
6004 — 账号已锁定
6101 — 订单不存在
6102 — 订单状态异常
6103 — 库存不足
6201 — 文件上传失败
6202 — 文件大小超限@Getter
@AllArgsConstructor
public enum ErrorCode {
// ===== 通用 =====
SUCCESS(200, "操作成功"),
PARAM_INVALID(400, "参数校验失败"),
UNAUTHORIZED(401, "未认证,请先登录"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "请求的资源不存在"),
METHOD_NOT_SUPPORTED(405, "请求方法不支持"),
UNSUPPORTED_MEDIA_TYPE(415, "不支持的媒体类型"),
TOO_MANY_REQUESTS(429, "请求过于频繁"),
// ===== 系统错误 =====
SYSTEM_ERROR(500, "系统内部错误"),
SERVICE_UNAVAILABLE(503, "服务暂不可用"),
GATEWAY_TIMEOUT(504, "上游服务超时"),
// ===== 业务错误:用户模块 6001-6099 =====
USER_NOT_FOUND(6001, "用户不存在"),
USERNAME_EXISTS(6002, "用户名已存在"),
PASSWORD_ERROR(6003, "密码错误"),
ACCOUNT_LOCKED(6004, "账号已锁定"),
USER_DISABLED(6005, "账号已被禁用"),
// ===== 业务错误:订单模块 6101-6199 =====
ORDER_NOT_FOUND(6101, "订单不存在"),
ORDER_STATUS_INVALID(6102, "订单状态异常"),
STOCK_NOT_ENOUGH(6103, "库存不足"),
ORDER_EXPIRED(6104, "订单已超时关闭"),
// ===== 业务错误:文件模块 6201-6299 =====
FILE_UPLOAD_FAILED(6201, "文件上传失败"),
FILE_SIZE_EXCEED(6202, "文件大小超过限制"),
FILE_TYPE_NOT_ALLOWED(6203, "文件类型不允许"),
// ===== 业务错误:通用业务 6301-6399 =====
DATA_CONFLICT(6301, "数据冲突,请刷新后重试"),
OPERATION_NOT_ALLOWED(6302, "当前状态不允许该操作"),
DEPENDENT_DATA_EXISTS(6303, "存在关联数据,无法删除");
private final int code;
private final String message;
}参数校验
Jakarta Bean Validation
Jakarta Bean Validation(原 JSR 303 / JSR 380)是 Java 生态的事实标准校验规范。Spring Boot 通过 spring-boot-starter-validation 自动集成 Hibernate Validator 实现。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>核心注解
| 注解 | 约束 | 适用类型 | 示例 |
|---|---|---|---|
@NotNull | 值不能为 null | 任意引用类型 | @NotNull String name |
@NotEmpty | 不能为 null,且长度/大小 > 0 | String / Collection / Map / 数组 | @NotEmpty List<String> tags |
@NotBlank | 不能为 null,且 trim 后长度 > 0 | String | @NotBlank String username |
@Size | 长度/大小在指定范围内 | String / Collection / Map / 数组 | @Size(min=6, max=20) String password |
@Min / @Max | 数值范围 | Number 子类 | @Min(1) @Max(120) int age |
@Positive / @PositiveOrZero | 正数 / 非负 | Number | @Positive BigDecimal price |
@Pattern | 正则匹配 | String | @Pattern(regexp = "^1[3-9]\\d{9}$") String phone |
@Email | 邮件格式 | String | @Email String email |
@Future / @Past | 未来/过去时间 | Temporal 子类 | @Future LocalDate expireDate |
@AssertTrue / @AssertFalse | 布尔值校验 | Boolean | @AssertTrue boolean agreed |
完整示例
@Data
@ApiModel(description = "用户创建请求")
public class UserCreateRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 32, message = "用户名长度需在 2-32 之间")
@ApiModelProperty(value = "用户名", required = true)
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度需在 6-64 之间")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).+$", message = "密码必须包含字母和数字")
@ApiModelProperty(value = "密码", required = true)
private String password;
@NotBlank(message = "昵称不能为空")
@Size(max = 50, message = "昵称最长 50 个字符")
@ApiModelProperty(value = "昵称")
private String nickname;
@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄最小为 1")
@Max(value = 150, message = "年龄最大为 150")
@ApiModelProperty(value = "年龄", example = "25")
private Integer age;
@Email(message = "邮箱格式不正确")
@Size(max = 100, message = "邮箱最长 100 个字符")
@ApiModelProperty(value = "邮箱")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@ApiModelProperty(value = "手机号")
private String phone;
}@RestController
@RequestMapping("/users")
@Validated
public class UserController {
@PostMapping
public ApiResponse<UserVO> createUser(
@Valid @RequestBody UserCreateRequest request) {
// 参数校验通过后,业务代码无需再校验
UserVO user = userService.create(request);
return ApiResponse.success(user);
}
@GetMapping("/{id}")
public ApiResponse<UserVO> getUser(
@PathVariable @Min(value = 1, message = "ID 必须大于 0") Long id) {
UserVO user = userService.findById(id);
return ApiResponse.success(user);
}
}分组校验
同一实体在不同操作下有不同的校验规则,使用分组实现。
public interface CreateGroup { }
public interface UpdateGroup { }@Data
public class UserRequest {
@Null(groups = CreateGroup.class, message = "新增时 ID 必须为空")
@NotNull(groups = UpdateGroup.class, message = "更新时 ID 不能为空")
private Long id;
@NotBlank(groups = CreateGroup.class, message = "新增时用户名不能为空")
@Size(min = 2, max = 32, message = "用户名长度需在 2-32 之间")
private String username;
@NotBlank(groups = CreateGroup.class, message = "新增时密码不能为空")
@Size(min = 6, max = 64, message = "密码长度需在 6-64 之间")
private String password;
@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄最小为 1")
@Max(value = 150, message = "年龄最大为 150")
private Integer age;
}@PostMapping
public ApiResponse<UserVO> createUser(
@Validated(CreateGroup.class) @RequestBody UserRequest request) {
return ApiResponse.success(userService.create(request));
}
@PutMapping
public ApiResponse<UserVO> updateUser(
@Validated(UpdateGroup.class) @RequestBody UserRequest request) {
return ApiResponse.success(userService.update(request));
}不指定 groups 时,校验属于 Default 组。@Validated(CreateGroup.class) 会先校验 CreateGroup 组的约束,再校验 Default 组的约束——前提是被校验的约束上同时标记了 groups = Default.class 或未标注 groups。
嵌套校验
当实体中包含其他实体时,需要级联校验。
@Data
public class OrderCreateRequest {
@NotBlank(message = "订单号不能为空")
private String orderNo;
@Valid // 触发嵌套校验
@NotNull(message = "收货地址不能为空")
private Address address;
@Valid // 触发嵌套校验
@NotEmpty(message = "商品列表不能为空")
private List<@Valid OrderItem> items;
}
@Data
public class Address {
@NotBlank(message = "省不能为空")
private String province;
@NotBlank(message = "市不能为空")
private String city;
@NotBlank(message = "详细地址不能为空")
private String detail;
}
@Data
public class OrderItem {
@NotNull(message = "商品 ID 不能为空")
private Long productId;
@Min(value = 1, message = "数量至少为 1")
private Integer quantity;
}@Validated vs @Valid 区别
| 维度 | @Valid(Jakarta) | @Validated(Spring) |
|---|---|---|
| 标准 | Jakarta EE 标准 | Spring 扩展 |
| 分组校验 | ❌ 不支持 | ✅ 支持 @Validated(Group.class) |
| 嵌套校验 | ✅ 支持 | ✅ 支持(需配合 @Valid) |
| 使用位置 | 方法参数、字段、属性 | 类级别、方法参数、方法 |
| 类级别校验 | ❌ 不支持 | ✅ 支持(类上标注触发方法级校验) |
最佳实践:Controller 方法参数上使用 @Validated(获得分组校验能力),字段级嵌套校验仍然使用 @Valid。
自定义校验注解
当内置注解无法满足业务规则时,自定义校验注解。
场景:枚举值校验
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValid {
String message() default "枚举值不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** 枚举类 */
Class<? extends Enum<?>> enumClass();
/** 校验方式:比较 name 还是 ordinal */
String method() default "name";
/** 是否允许为 null */
boolean allowNull() default false;
}public class EnumValidator implements ConstraintValidator<EnumValid, Object> {
private EnumValid annotation;
@Override
public void initialize(EnumValid constraintAnnotation) {
this.annotation = constraintAnnotation;
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return annotation.allowNull();
}
Enum<?>[] enumConstants = annotation.enumClass().getEnumConstants();
if (enumConstants == null) {
return false;
}
String method = annotation.method();
for (Enum<?> e : enumConstants) {
if ("name".equals(method) && e.name().equals(value)) {
return true;
}
if ("ordinal".equals(method) && e.ordinal() == (int) value) {
return true;
}
// 支持通过 toString 比较
if (e.toString().equals(value.toString())) {
return true;
}
}
return false;
}
}public enum UserStatus {
ACTIVE, INACTIVE, FROZEN
}
@Data
public class UserQueryRequest {
@EnumValid(enumClass = UserStatus.class, message = "用户状态不合法")
private String status;
}场景:手机号校验
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}public class PhoneValidator implements ConstraintValidator<Phone, String> {
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true; // 非空校验交给 @NotBlank
}
return PHONE_PATTERN.matcher(value).matches();
}
}@Data
public class RegisterRequest {
@NotBlank
@Phone
private String phone;
}统一响应体
统一返回格式
接口响应的 JSON 结构必须统一,客户端可以据此编写通用处理逻辑。
// 成功响应
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1,
"username": "alice",
"email": "alice@example.com"
},
"timestamp": "2026-07-05T10:30:00"
}
// 失败响应
{
"code": 6001,
"message": "用户不存在",
"data": null,
"timestamp": "2026-07-05T10:30:00"
}
// 分页响应
{
"code": 200,
"message": "操作成功",
"data": {
"records": [ ... ],
"total": 100,
"page": 1,
"size": 10,
"pages": 10
},
"timestamp": "2026-07-05T10:30:00"
}分页响应
@Data
@ApiModel(description = "分页响应")
public class PageResult<T> {
@ApiModelProperty(value = "数据列表")
private List<T> records;
@ApiModelProperty(value = "总记录数", example = "100")
private long total;
@ApiModelProperty(value = "当前页码", example = "1")
private long page;
@ApiModelProperty(value = "每页大小", example = "10")
private long size;
@ApiModelProperty(value = "总页数", example = "10")
private long pages;
public static <T> PageResult<T> of(List<T> records, long total, long page, long size) {
PageResult<T> result = new PageResult<>();
result.setRecords(records);
result.setTotal(total);
result.setPage(page);
result.setSize(size);
result.setPages(size == 0 ? 0 : (total + size - 1) / size);
return result;
}
public static <T> PageResult<T> of(IPage<T> page) {
return of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize());
}
}@GetMapping("/users")
public ApiResponse<PageResult<UserVO>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
IPage<UserVO> pageResult = userService.page(page, size);
return ApiResponse.success(PageResult.of(pageResult));
}响应体的 Swagger 集成
使用 SpringDoc OpenAPI(Swagger 3)为统一响应体生成 API 文档,避免每个接口重复描述响应结构。
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info().title("用户服务 API").version("1.0.0"))
.components(new Components()
.addSchemas("ApiResponse", new Schema<>()
.addProperty("code", new IntegerSchema().example(200))
.addProperty("message", new StringSchema().example("操作成功"))
.addProperty("data", new Schema<>())
.addProperty("timestamp", new StringSchema().example("2026-07-05T10:30:00")))
.addSchemas("PageResult", new Schema<>()
.addProperty("records", new ArraySchema().items(new Schema<>()))
.addProperty("total", new IntegerSchema().example(100))
.addProperty("page", new IntegerSchema().example(1))
.addProperty("size", new IntegerSchema().example(10))
.addProperty("pages", new IntegerSchema().example(10))));
}
}使用 @ApiResponse 注解描述接口可能返回的错误码:
@Operation(summary = "创建用户")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "创建成功"),
@ApiResponse(responseCode = "400", description = "参数校验失败",
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@ApiResponse(responseCode = "6002", description = "用户名已存在",
content = @Content(schema = @Schema(implementation = ApiResponse.class)))
})
@PostMapping
public ApiResponse<UserVO> createUser(@Valid @RequestBody UserCreateRequest request) {
return ApiResponse.success(userService.create(request));
}高级实践
国际化错误消息(i18n)
支持多语言环境下错误消息的动态切换。
# messages.properties(默认:中文)
error.param.invalid=参数校验失败
error.user.notfound=用户不存在
error.user.username.exists=用户名已存在
error.system.error=系统内部错误
# messages_en.properties(英文)
error.param.invalid=Parameter validation failed
error.user.notfound=User not found
error.user.username.exists=Username already exists
error.system.error=Internal system error@Component
public class MessageSourceHelper {
@Autowired
private MessageSource messageSource;
public String get(String code, Object... args) {
// 从当前请求上下文中获取语言偏好
Locale locale = LocaleContextHolder.getLocale();
try {
return messageSource.getMessage(code, args, locale);
} catch (NoSuchMessageException e) {
return code; // 找不到时返回 code 本身
}
}
public String get(ErrorCode errorCode, Object... args) {
return get("error." + errorCode.name().toLowerCase()
.replace('_', '.'), args);
}
}@Slf4j
public class I18nBizException extends BizException {
private final String messageKey;
public I18nBizException(int code, String messageKey, Object... args) {
super(code, resolveMessage(messageKey, args));
this.messageKey = messageKey;
}
private static String resolveMessage(String messageKey, Object... args) {
// 通过 MessageSourceHelper 加载国际化消息
MessageSourceHelper helper = SpringContextHolder.getBean(MessageSourceHelper.class);
return helper.get(messageKey, args);
}
}LocaleContextHolder 工作原理:Spring 的 DispatcherServlet 在处理请求时,会根据 Accept-Language 头或配置的 LocaleResolver 将 Locale 绑定到 LocaleContextHolder,在请求处理链路中全局可访问。
参数校验异常处理
Spring 对参数校验失败抛出不同的异常:
| 触发场景 | 异常类型 | 说明 |
|---|---|---|
@Valid + @RequestBody | MethodArgumentNotValidException | 请求体校验失败 |
@Validated + @RequestParam / @PathVariable | ConstraintViolationException | 路径/查询参数校验失败 |
@Valid + @ModelAttribute | BindException | 表单绑定校验失败 |
@RestControllerAdvice
public class ValidationExceptionHandler {
/** 请求体参数校验失败 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
String msg = fieldErrors.stream()
.map(f -> String.format("[%s] %s", f.getField(), f.getDefaultMessage()))
.collect(Collectors.joining("; "));
log.warn("请求体参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
/** 路径/查询参数校验失败 */
@ExceptionHandler(ConstraintViolationException.class)
public ApiResponse<Void> handleConstraintViolation(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(v -> String.format("[%s] %s",
v.getPropertyPath().toString(), v.getMessage()))
.collect(Collectors.joining("; "));
log.warn("查询参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
/** 表单绑定校验失败 */
@ExceptionHandler(BindException.class)
public ApiResponse<Void> handleBind(BindException e) {
String msg = e.getFieldErrors().stream()
.map(f -> String.format("[%s] %s", f.getField(), f.getDefaultMessage()))
.collect(Collectors.joining("; "));
log.warn("表单参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
}断言工具类
断言工具类能够在代码中快速做前置条件检查,抛出带错误码的业务异常,替代冗余的 if-throw 代码。
/**
* 业务断言工具类
* 条件不满足时抛出 BizException,携带明确的错误码
*/
public class BizAsserts {
public static void notNull(Object obj, ErrorCode errorCode) {
if (obj == null) {
throw new BizException(errorCode);
}
}
public static void notNull(Object obj, int code, String message) {
if (obj == null) {
throw new BizException(code, message);
}
}
public static void isTrue(boolean expression, ErrorCode errorCode) {
if (!expression) {
throw new BizException(errorCode);
}
}
public static void isTrue(boolean expression, int code, String message) {
if (!expression) {
throw new BizException(code, message);
}
}
public static void notEmpty(Collection<?> collection, ErrorCode errorCode) {
if (collection == null || collection.isEmpty()) {
throw new BizException(errorCode);
}
}
public static void notBlank(String str, ErrorCode errorCode) {
if (str == null || str.isBlank()) {
throw new BizException(errorCode);
}
}
public static void equals(Object expected, Object actual, ErrorCode errorCode) {
if (!Objects.equals(expected, actual)) {
throw new BizException(errorCode);
}
}
public static void state(boolean expression, ErrorCode errorCode) {
if (!expression) {
throw new BizException(errorCode);
}
}
}使用对比:
// 不使用断言
public User findById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return user;
}
// 使用断言(一行替代三行)
public User findById(Long id) {
User user = userMapper.selectById(id);
BizAsserts.notNull(user, ErrorCode.USER_NOT_FOUND);
return user;
}防重复提交与幂等校验结合
统一异常处理可以与防重复提交机制配合,提供全局一致的重复请求响应。
/**
* 防重复提交注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** 幂等 key 的前缀 */
String prefix() default "idempotent";
/** key 的 SpEL 表达式,通常从请求参数中取值 */
String key();
/** 过期时间(秒) */
int ttl() default 5;
/** 提示消息 */
String message() default "请勿重复提交";
}@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 解析 key
String key = parseKey(joinPoint, idempotent);
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(idempotent.ttl()));
if (Boolean.FALSE.equals(success)) {
// 复用统一异常处理
throw new BizException(ErrorCode.TOO_MANY_REQUESTS.getCode(), idempotent.message());
}
try {
return joinPoint.proceed();
} catch (Exception e) {
// 业务异常时清除幂等标记(可选)
redisTemplate.delete(key);
throw e;
}
}
private String parseKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
// 使用 SpEL 解析 key 表达式
// 简化实现:实际项目中可使用 SpelExpressionParser
return idempotent.prefix() + ":" + idempotent.key();
}
}@PostMapping("/order")
@Idempotent(key = "#request.orderNo", message = "订单正在处理,请勿重复提交")
public ApiResponse<OrderVO> createOrder(@Valid @RequestBody OrderCreateRequest request) {
return ApiResponse.success(orderService.create(request));
}当重复提交被拦截时,全局异常处理器捕获 BizException 并返回统一格式的响应:
{
"code": 429,
"message": "订单正在处理,请勿重复提交",
"data": null,
"timestamp": "2026-07-05T10:30:00"
}代码示例
完整全局异常处理类
package com.example.common.exception;
import com.example.common.api.ApiResponse;
import com.example.common.api.ErrorCode;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
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.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.servlet.NoHandlerFoundException;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ==================== 参数校验异常 ====================
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> String.format("[%s] %s", f.getField(), f.getDefaultMessage()))
.collect(Collectors.joining("; "));
log.warn("请求体参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
@ExceptionHandler(ConstraintViolationException.class)
public ApiResponse<Void> handleConstraintViolation(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(v -> String.format("[%s] %s",
v.getPropertyPath().toString(), v.getMessage()))
.collect(Collectors.joining("; "));
log.warn("查询参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
@ExceptionHandler(BindException.class)
public ApiResponse<Void> handleBind(BindException e) {
String msg = e.getFieldErrors().stream()
.map(f -> String.format("[%s] %s", f.getField(), f.getDefaultMessage()))
.collect(Collectors.joining("; "));
log.warn("表单参数校验失败: {}", msg);
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
// ==================== 参数格式异常 ====================
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
String msg = "请求体格式错误";
// 提取更精确的错误信息
Throwable cause = e.getCause();
if (cause instanceof InvalidFormatException ife) {
msg = String.format("参数 [%s] 格式不正确", ife.getPath().stream()
.map(r -> r.getFieldName())
.collect(Collectors.joining(".")));
}
log.warn("请求体格式错误: {}", e.getMessage());
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
String msg = String.format("参数 [%s] 类型不匹配,期望类型: %s",
e.getName(), e.getRequiredType().getSimpleName());
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(), msg);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ApiResponse<Void> handleMissingParam(MissingServletRequestParameterException e) {
return ApiResponse.error(ErrorCode.PARAM_INVALID.getCode(),
"缺少必要参数: " + e.getParameterName());
}
// ==================== 业务异常 ====================
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBiz(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ApiResponse.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(ServiceException.class)
public ApiResponse<Object> handleService(ServiceException e) {
log.warn("服务异常: code={}, message={}, data={}", e.getCode(), e.getMessage(), e.getData());
return ApiResponse.error(e.getCode(), e.getMessage(), e.getData());
}
// ==================== HTTP 异常 ====================
@ExceptionHandler(NoHandlerFoundException.class)
public ApiResponse<Void> handleNotFound(NoHandlerFoundException e) {
return ApiResponse.error(ErrorCode.NOT_FOUND.getCode(), "请求的资源不存在");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ApiResponse<Void> handleMethodNotSupport(HttpRequestMethodNotSupportedException e) {
return ApiResponse.error(ErrorCode.METHOD_NOT_SUPPORTED.getCode(),
"请求方法不支持: " + e.getMethod());
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ApiResponse<Void> handleMediaTypeNotSupport(HttpMediaTypeNotSupportedException e) {
return ApiResponse.error(ErrorCode.UNSUPPORTED_MEDIA_TYPE.getCode(),
"不支持的媒体类型: " + e.getContentType());
}
// ==================== 系统异常 ====================
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleUnknown(Exception e) {
log.error("系统未知异常", e);
return ApiResponse.error(ErrorCode.SYSTEM_ERROR.getCode(), "系统繁忙,请稍后重试");
}
}自定义业务异常
package com.example.common.exception;
import com.example.common.api.ErrorCode;
import lombok.Getter;
/**
* 业务异常基类
*/
@Getter
public class BizException extends RuntimeException {
private final int code;
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public BizException(ErrorCode errorCode, Object... args) {
super(String.format(errorCode.getMessage(), args));
this.code = errorCode.getCode();
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}package com.example.common.exception;
import lombok.Getter;
/**
* 带错误数据的业务异常
* 适用于需要返回额外错误信息的场景
*/
@Getter
public class ServiceException extends BizException {
private final transient Object data;
public ServiceException(int code, String message) {
super(code, message);
this.data = null;
}
public ServiceException(int code, String message, Object data) {
super(code, message);
this.data = data;
}
public ServiceException(ErrorCode errorCode, Object data) {
super(errorCode.getCode(), errorCode.getMessage());
this.data = data;
}
}参数校验分组
/**
* 校验分组:新增
*/
public interface CreateGroup {
}
/**
* 校验分组:更新
*/
public interface UpdateGroup {
}
/**
* 校验分组:查询
*/
public interface QueryGroup {
}@Data
public class UserSaveRequest {
@Null(groups = CreateGroup.class, message = "新增时 ID 必须为空")
@NotNull(groups = UpdateGroup.class, message = "更新时 ID 不能为空")
private Long id;
@NotBlank(groups = CreateGroup.class, message = "用户名不能为空")
@Size(min = 2, max = 32, message = "用户名长度需在 2-32 之间")
private String username;
@NotBlank(groups = CreateGroup.class, message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度需在 6-64 之间")
private String password;
@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄最小为 1")
@Max(value = 150, message = "年龄最大为 150")
private Integer age;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class}, message = "昵称不能为空")
@Size(max = 50, message = "昵称最长 50 个字符")
private String nickname;
}@RestController
@RequestMapping("/users")
@Validated
public class UserController {
@PostMapping
public ApiResponse<UserVO> createUser(
@Validated(CreateGroup.class) @RequestBody UserSaveRequest request) {
return ApiResponse.success(userService.create(request));
}
@PutMapping
public ApiResponse<UserVO> updateUser(
@Validated(UpdateGroup.class) @RequestBody UserSaveRequest request) {
return ApiResponse.success(userService.update(request));
}
}错误码枚举
package com.example.common.api;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 错误码枚举
*
* 编码规则:
* 2xx — 通用成功/失败
* 4xx — 客户端错误(与 HTTP 状态码对应)
* 5xx — 服务端错误
* 6xxx — 业务错误码(前两位标识业务模块)
*/
@Getter
@AllArgsConstructor
public enum ErrorCode {
// ==================== 通用 ====================
SUCCESS(200, "操作成功"),
PARAM_INVALID(400, "参数校验失败"),
UNAUTHORIZED(401, "未认证,请先登录"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "请求的资源不存在"),
METHOD_NOT_SUPPORTED(405, "请求方法不支持"),
UNSUPPORTED_MEDIA_TYPE(415, "不支持的媒体类型"),
TOO_MANY_REQUESTS(429, "请求过于频繁"),
// ==================== 系统错误 ====================
SYSTEM_ERROR(500, "系统内部错误"),
SERVICE_UNAVAILABLE(503, "服务暂不可用"),
GATEWAY_TIMEOUT(504, "上游服务超时"),
// ==================== 用户模块 6001-6099 ====================
USER_NOT_FOUND(6001, "用户不存在"),
USERNAME_EXISTS(6002, "用户名已存在"),
PASSWORD_ERROR(6003, "密码错误"),
ACCOUNT_LOCKED(6004, "账号已锁定"),
USER_DISABLED(6005, "账号已被禁用"),
USER_PHONE_EXISTS(6006, "手机号已被注册"),
USER_EMAIL_EXISTS(6007, "邮箱已被注册"),
// ==================== 订单模块 6101-6199 ====================
ORDER_NOT_FOUND(6101, "订单不存在"),
ORDER_STATUS_INVALID(6102, "订单状态异常"),
STOCK_NOT_ENOUGH(6103, "库存不足"),
ORDER_EXPIRED(6104, "订单已超时关闭"),
ORDER_ALREADY_PAID(6105, "订单已支付"),
// ==================== 文件模块 6201-6299 ====================
FILE_UPLOAD_FAILED(6201, "文件上传失败"),
FILE_SIZE_EXCEED(6202, "文件大小超过限制"),
FILE_TYPE_NOT_ALLOWED(6203, "文件类型不允许"),
// ==================== 通用业务 6301-6399 ====================
DATA_CONFLICT(6301, "数据冲突,请刷新后重试"),
OPERATION_NOT_ALLOWED(6302, "当前状态不允许该操作"),
DEPENDENT_DATA_EXISTS(6303, "存在关联数据,无法删除"),
// ==================== 认证授权 6401-6499 ====================
TOKEN_EXPIRED(6401, "Token 已过期"),
TOKEN_INVALID(6402, "Token 无效"),
REFRESH_TOKEN_EXPIRED(6403, "RefreshToken 已过期"),
PERMISSION_DENIED(6404, "权限不足");
private final int code;
private final String message;
}总结
统一异常处理与参数校验的最佳实践可以总结为以下关键点:
| 关注点 | 最佳实践 |
|---|---|
| 异常处理方式 | 使用 @RestControllerAdvice 全局拦截,业务代码仅抛出异常 |
| 业务异常 | 继承 RuntimeException,定义 BizException 基类携带错误码 |
| 错误码设计 | 按模块分段,使用枚举统一管理,避免硬编码字符串 |
| 参数校验 | 使用 Jakarta Bean Validation 注解声明式校验,避免手写校验代码 |
| 分组校验 | 利用 @Validated(Groups.class) 适应不同操作场景的校验差异 |
| 嵌套校验 | 使用 @Valid 触发级联校验,确保复杂对象完整验证 |
| 断言工具 | 通过 BizAsserts 工具类减少繁琐的 if-throw 代码 |
| 响应体统一 | 所有接口返回 ApiResponse<T> 格式,客户端一键解析 |
| 国际化 | 使用 MessageSource 支持多语言,错误消息与代码分离 |