Spring Boot 3 全局异常处理与统一响应封装进阶实战
- [Spring Boot 3 全局异常处理与统一响应封装进阶实战](#Spring Boot 3 全局异常处理与统一响应封装进阶实战)
-
- 写在前面:为什么还要写这个话题?
- 一、整体架构设计
- 二、统一响应格式设计
-
- [2.1 响应体结构定义](#2.1 响应体结构定义)
- [2.2 错误码枚举](#2.2 错误码枚举)
- 三、自定义异常体系
-
- [3.1 基础业务异常](#3.1 基础业务异常)
- [3.2 参数校验异常(增强版)](#3.2 参数校验异常(增强版))
- [3.3 第三方调用异常](#3.3 第三方调用异常)
- [四、★ 核心:全局异常处理器](#四、★ 核心:全局异常处理器)
- 五、在业务代码中的使用方式
-
- [5.1 Controller 层](#5.1 Controller 层)
- [5.2 Service 层](#5.2 Service 层)
- [5.3 DTO 参数校验](#5.3 DTO 参数校验)
- 六、高级特性:异常日志与告警集成
-
- [6.1 结构化日志输出](#6.1 结构化日志输出)
- [6.2 异常告警(关键异常自动通知)](#6.2 异常告警(关键异常自动通知))
- [七、与 OpenFeign / RestTemplate 的集成](#七、与 OpenFeign / RestTemplate 的集成)
-
- [7.1 Feign ErrorDecoder](#7.1 Feign ErrorDecoder)
- 八、常见踩坑记录
-
- [坑1:@ControllerAdvice 和 @RestControllerAdvice 搞混](#坑1:@ControllerAdvice 和 @RestControllerAdvice 搞混)
- 坑2:异常处理器之间的顺序问题
- [坑3:Filter 中抛出的异常无法被 @RestControllerAdvice 捕获](#坑3:Filter 中抛出的异常无法被 @RestControllerAdvice 捕获)
- [坑4:@Valid 和 @Validated 的区别](#坑4:@Valid 和 @Validated 的区别)
- 坑5:异常信息中包含敏感数据
- 坑6:异步线程池中的异常丢失
Spring Boot 3 全局异常处理与统一响应封装进阶实战
写在前面:为什么还要写这个话题?
全局异常处理和统一响应封装是 Spring Boot 项目里最基础也最容易被搞砸的基础设施之一。
我见过太多项目的"全局异常处理"就是随便写个 @RestControllerAdvice + @ExceptionHandler(Exception.class) 然后把所有异常都包装成 {"code":500,"msg":"服务器内部错误"} ------ 这跟没处理有什么区别?线上出了问题排查全靠猜,前端拿到一个 500 错误码完全不知道该给用户弹什么提示。
真正好的异常处理体系应该做到:
- 异常分类清晰:业务异常、参数校验异常、权限异常、第三方调用异常......每种都有独立的错误码和提示
- 错误信息分层:返回给前端的用户友好信息 vs 写入日志的详细堆栈 vs 发送到告警系统的关键异常
- 统一响应格式:所有接口(包括正常和异常)都用同一套 JSON 结构,前端只需要一套解析逻辑
- 可扩展性强:新增业务时只需定义新的异常类和错误码枚举,不需要改框架代码
这篇文章基于我们团队经过三个大项目验证的方案,从基础到进阶全部讲透。
一、整体架构设计
┌─────────────────────────────────────────────────────────────┐
│ 异常处理架构全景图 │
│ │
│ Controller 层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ @Valid │ │ 业务逻辑 │ │ 外部调用 │ │
│ │ 参数校验 │ │ 抛出自定义 │ │ HTTP/RPC │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ GlobalExceptionHandler │ │
│ │ (@RestControllerAdvice) │ │
│ │ │ │
│ │ @ExceptionHandler │ │
│ │ ├─ MethodArgumentNotValidException → 400 │ │
│ │ ├─ BusinessException → 自定义错误码 │ │
│ │ ├─ AccessDeniedException → 401/403 │ │
│ │ ├─ FeignException / RpcException → 502 │ │
│ │ ├─ ConstraintViolationException → 400 │ │
│ │ └─ Exception → 500 (兜底) │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Result<T> 统一响应封装 │ │
│ │ { code, message, data, traceId } │ │
│ └──────────────────┬──────────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ 前端展示 日志记录 告警通知 │
└─────────────────────────────────────────────────────────────┘
二、统一响应格式设计
2.1 响应体结构定义
java
package com.example.common.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一 API 响应结构
*
* 设计原则:
* - 所有接口(成功/失败)使用相同结构
* - 泛型支持任意数据类型
* - traceId 用于链路追踪
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> {
/** 业务状态码(非 HTTP 状态码!) */
private int code;
/** 用户可读的消息 */
private String message;
/** 业务数据(可能为 null) */
private T data;
/** 链路追踪 ID(用于日志关联) */
private String traceId;
/** 时间戳 */
private long timestamp;
// ==================== 工厂方法 ====================
public static <T> Result<T> ok(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(),
ResultCode.SUCCESS.getMessage(), data, TraceUtil.getTraceId(), System.currentTimeMillis());
}
public static <T> Result<T> ok() {
return ok(null);
}
public static <T> Result<T> fail(ResultCode resultCode) {
return new Result<>(resultCode.getCode(), resultCode.getMessage(),
null, TraceUtil.getTraceId(), System.currentTimeMillis());
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null, TraceUtil.getTraceId(), System.currentTimeMillis());
}
public static <T> Result<T> fail(BusinessException ex) {
return new Result<>(ex.getCode(), ex.getMessage(), null,
TraceUtil.getTraceId(), System.currentTimeMillis());
}
}
2.2 错误码枚举
java
package com.example.common.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 全局错误码定义
*
* 编码规则:
* - 1xx: 成功相关
* - 4xx: 客户端错误(参数、权限等)
* - 5xx: 服务端错误(业务、系统)
* - 6xx: 第三方服务错误
* - 9xx: 保留/未定义
*/
@Getter
@AllArgsConstructor
public enum ResultCode {
// ===== 成功 =====
SUCCESS(200, "操作成功"),
CREATED(201, "创建成功"),
// ===== 客户端错误 4xx =====
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或登录已过期"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "资源不存在"),
METHOD_NOT_ALLOWED(405, "请求方法不支持"),
TOO_MANY_REQUESTS(429, "请求过于频繁,请稍后再试"),
// 参数校验子码 (41xx)
PARAM_ERROR(4001, "参数校验失败"),
PARAM_MISSING(4002, "缺少必要参数"),
PARAM_FORMAT_ERROR(4003, "参数格式不合法"),
PARAM_RANGE_ERROR(4004, "参数值超出允许范围"),
// ===== 业务错误 5xx =====
BUSINESS_ERROR(5001, "业务处理失败"),
ORDER_NOT_FOUND(5002, "订单不存在"),
ORDER_STATUS_ERROR(5003, "订单状态不允许此操作"),
INSUFFICIENT_BALANCE(5004, "余额不足"),
STOCK_INSUFFICIENT(5005, "库存不足"),
DUPLICATE_OPERATION(5006, "请勿重复操作"),
CAPTCHA_ERROR(5007, "验证码错误或已过期"),
// ===== 系统错误 5xxx =====
SYSTEM_ERROR(5000, "系统繁忙,请稍后重试"),
DATABASE_ERROR(5008, "数据库操作异常"),
CACHE_ERROR(5009, "缓存服务异常"),
MQ_ERROR(5010, "消息队列异常"),
// ===== 第三方错误 6xx =====
THIRD_PARTY_ERROR(6001, "第三方服务异常"),
PAYMENT_ERROR(6002, "支付服务异常"),
SMS_ERROR(6003, "短信发送失败"),
FILE_UPLOAD_ERROR(6004, "文件上传失败");
private final int code;
private final String message;
}
三、自定义异常体系
3.1 基础业务异常
java
package com.example.common.exception;
import lombok.Getter;
import com.example.common.response.ResultCode;
/**
* 业务异常基类
*
* 使用方式:
* throw new BusinessException(ResultCode.ORDER_NOT_FOUND);
* throw new BusinessException(ResultCode.INSUFFICIENT_BALANCE, "当前余额: 100, 需要: 200");
*/
@Getter
public class BusinessException extends RuntimeException {
private final int code;
private final String errorMessage;
public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
this.errorMessage = resultCode.getMessage();
}
public BusinessException(ResultCode resultCode, String detailMessage) {
super(resultCode.getMessage() + ": " + detailMessage);
this.code = resultCode.getCode();
this.errorMessage = detailMessage;
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
this.errorMessage = message;
}
}
3.2 参数校验异常(增强版)
java
package com.example.common.exception;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 参数校验异常(携带具体字段级别的错误信息)
*
* 返回示例:
* {
* "code": 4001,
* "message": "参数校验失败",
* "data": {
* "errors": [
* {"field": "username", "message": "用户名长度需在3-20之间"},
* {"field": "email", "message": "邮箱格式不正确"}
* ]
* }
* }
*/
@Getter
public class ValidationException extends RuntimeException {
private final Map<String, String> fieldErrors;
public ValidationException(String message, Map<String, String> fieldErrors) {
super(message);
this.fieldErrors = fieldErrors;
}
/**
* 从 Spring 的 MethodArgumentNotValidException 构建
*/
public static ValidationException fromBindingResult(
org.springframework.validation.BindingResult result) {
var errors = new LinkedHashMap<String, String>();
for (var error : result.getFieldErrors()) {
errors.put(error.getField(),
error.getDefaultMessage() != null ? error.getDefaultMessage() : "校验失败");
}
for (var error : result.getGlobalErrors()) {
errors.put(error.getObjectName(),
error.getDefaultMessage() != null ? error.getDefaultMessage() : "校验失败");
}
return new ValidationException("参数校验失败", errors);
}
}
3.3 第三方调用异常
java
package com.example.common.exception;
/**
* 第三方服务调用异常
*
* 用于封装来自外部服务的错误:
* - 支付网关返回的错误
* - 短信服务返回的错误
* - 文件存储服务返回的错误
*/
@Getter
public class ThirdPartyServiceException extends RuntimeException {
private final String serviceName; // 第三方服务名称
private final int thirdPartyCode; // 第三方原始错误码
private final String thirdPartyMsg; // 第三方原始错误消息
public ThirdPartyServiceException(String serviceName,
int thirdPartyCode,
String thirdPartyMsg) {
super(serviceName + " 调用失败 [" + thirdPartyCode + "]: " + thirdPartyMsg);
this.serviceName = serviceName;
this.thirdPartyCode = thirdPartyCode;
this.thirdPartyMsg = thirdPartyMsg;
}
public ThirdPartyServiceException(String serviceName, String message, Throwable cause) {
super(serviceName + ": " + message, cause);
this.serviceName = serviceName;
this.thirdPartyCode = -1;
this.thirdPartyMsg = message;
}
}
四、★ 核心:全局异常处理器
这是整个体系的核心组件------所有异常都在这里被捕获、分类、转换成统一的响应格式。
java
package com.example.common.exception;
import com.example.common.response.Result;
import com.example.common.response.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.BindException;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*
* 处理优先级(从高到低):
* 1. 自定义业务异常 → 返回对应错误码
* 2. 参数校验异常 → 返回字段级错误详情
* 3. 权限异常 → 返回 401/403
* 4. 第三方调用异常 → 返回 6xx 错误码
* 5. 未预期的 Exception → 记录详细日志 + 返回通用 500
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final HttpServletRequest request;
// ==================== 1. 业务异常 ====================
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("[BusinessException] code={}, msg={}, uri={}",
e.getCode(), e.getErrorMessage(), getRequestUri());
return Result.fail(e);
}
// ==================== 2. 参数校验异常 ====================
/**
* 处理 @RequestBody + @Valid 校验失败
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Map<String, String>> handleValidationException(
MethodArgumentNotValidException e) {
var fieldErrors = extractFieldErrors(e.getBindingResult());
log.warn("[Validation] uri={}, errors={}", getRequestUri(), fieldErrors);
return Result.fail(ResultCode.PARAM_ERROR.getCode(), "参数校验失败");
// 如果需要返回详细字段错误,可以用 data 字段携带
// return Result.fail(ResultCode.PARAM_ERROR.getCode(), "参数校验失败", Map.of("errors", fieldErrors));
}
/**
* 处理 @RequestParam / @PathVariable 类型转换失败
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
var name = e.getName();
var requiredType = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown";
var value = e.getValue();
log.warn("[TypeMismatch] param={}, requiredType={}, value={}", name, requiredType, value);
return Result.fail(ResultCode.PARAM_FORMAT_ERROR.getCode(),
String.format("参数 '%s' 应为 %s 类型,实际值: '%s'", name, requiredType, value));
}
/**
* 处理缺少必填参数
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("[MissingParam] param={}", e.getParameterName());
return Result.fail(ResultCode.PARAM_MISSING.getCode(),
String.format("缺少必要参数: %s", e.getParameterName()));
}
/**
* 处理 @Validated 方法级别校验(如 @NotBlank 直接标注在参数上)
*/
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Map<String, String>> handleConstraintViolation(
ConstraintViolationException e) {
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
var errors = violations.stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
v -> v.getMessage(),
(existing, replacement) -> existing,
LinkedHashMap::new
));
log.warn("[ConstraintViolation] uri={}, errors={}", getRequestUri(), errors);
return Result.fail(ResultCode.PARAM_ERROR.getCode(), "参数校验失败");
}
/**
* 表单绑定异常(@ModelAttribute 校验失败)
*/
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Map<String, String>> handleBindException(BindException e) {
var fieldErrors = extractFieldErrors(e.getBindingResult());
log.warn("[BindException] uri={}, errors={}", getRequestUri(), fieldErrors);
return Result.fail(ResultCode.PARAM_ERROR.getCode(), "参数校验失败");
}
// ==================== 3. 权限异常 ====================
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handleAccessDenied(AccessDeniedException e) {
log.warn("[AccessDenied] uri={}, user={}",
getRequestUri(), request.getRemoteUser());
return Result.fail(ResultCode.FORBIDDEN);
}
// ==================== 4. 第三方服务异常 ====================
@ExceptionHandler(ThirdPartyServiceException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleThirdPartyError(ThirdPartyServiceException e) {
log.error("[ThirdPartyError] service={}, code={}, msg={}, uri={}",
e.getServiceName(), e.getThirdPartyCode(), e.getThirdPartyMsg(),
getRequestUri(), e);
// 不暴露第三方原始错误细节给前端
return Result.fail(ResultCode.THIRD_PARTY_ERROR);
}
// ==================== 5. 文件上传异常 ====================
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public Result<Void> handleMaxUploadSize(MaxUploadSizeExceededException e) {
long maxSize = e.getMaxUploadSize();
log.warn("[FileTooLarge] maxSize={}MB, uri={}",
maxSize / 1024 / 1024, getRequestUri());
return Result.fail(ResultCode.PARAM_FORMAT_ERROR.getCode(),
String.format("文件大小超过限制(最大 %.1fMB)", maxSize / 1024.0 / 1024.0));
}
// ==================== 6. 兜底:未预期异常 ====================
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleUnexpectedException(Exception e) {
// ★ 关键:未知异常必须记录完整堆栈!
log.error("[UnexpectedException] uri={}, method={}",
getRequestUri(), request.getMethod(), e);
// 给前端返回通用错误信息,不要泄露内部细节
return Result.fail(ResultCode.SYSTEM_ERROR);
}
// ==================== 内部工具方法 ====================
private Map<String, String> extractFieldErrors(org.springframework.validation.BindingResult result) {
if (result == null || !result.hasErrors()) {
return Map.of();
}
return result.getFieldErrors().stream()
.filter(error -> error.getDefaultMessage() != null)
.collect(Collectors.toMap(
org.springframework.validation.FieldError::getField,
org.springframework.validation.FieldError::getDefaultMessage,
(existing, replacement) -> existing,
LinkedHashMap::new
));
}
private String getRequestUri() {
return request != null ? request.getRequestURI() : "unknown";
}
}
五、在业务代码中的使用方式
5.1 Controller 层
java
package com.example.order.controller;
import com.example.common.response.Result;
import com.example.common.response.ResultCode;
import com.example.common.exception.BusinessException;
import com.example.order.dto.CreateOrderRequest;
import com.example.order.dto.OrderDTO;
import com.example.order.service.OrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 订单接口 --- 演示统一异常处理的实际效果
*/
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
class OrderController {
private final OrderService orderService;
/**
* 创建订单
*
* 成功时返回:
* { "code": 200, "message": "操作成功", "data": { "id": 12345, ... }, "traceId": "abc123" }
*
* 参数校验失败时自动返回:
* { "code": 4001, "message": "参数校验失败", "traceId": "abc123" }
*/
@PostMapping
public Result<OrderDTO> createOrder(@Valid @RequestBody CreateOrderRequest req) {
var order = orderService.createOrder(req);
return Result.ok(order);
}
/**
* 查询订单
*
* 订单不存在时抛出 BusinessException → 自动转为:
* { "code": 5002, "message": "订单不存在", "traceId": "abc123" }
*/
@GetMapping("/{orderId}")
public Result<OrderDTO> getOrder(@PathVariable Long orderId) {
var order = orderService.getOrderById(orderId)
.orElseThrow(() -> new BusinessException(ResultCode.ORDER_NOT_FOUND));
return Result.ok(order);
}
/**
* 取消订单
*
* 状态不允许时抛出带详情信息的异常:
* { "code": 5003, "message": "订单状态不允许此操作: 当前状态=SHIPPED" }
*/
@PostMapping("/{orderId}/cancel")
public Result<Void> cancelOrder(@PathVariable Long orderId) {
try {
orderService.cancelOrder(orderId);
return Result.ok();
} catch (IllegalStateException e) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, e.getMessage());
}
}
}
5.2 Service 层
java
package com.example.order.service;
import com.example.common.exception.BusinessException;
import com.example.common.exception.ThirdPartyServiceException;
import com.example.common.response.ResultCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 订单业务逻辑层
*
* 异常处理策略:
* - 可预见的业务错误 → 抛 BusinessException(会被全局处理器捕获并友好返回)
* - 第三方调用失败 → 抛 ThirdPartyServiceException(会记录详细日志但只返回通用错误)
* - 真正的系统级异常 → 不捕获,让全局兜底处理器处理
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
private final PaymentClient paymentClient;
private final StockClient stockClient;
@Transactional
public OrderDTO createOrder(CreateOrderRequest req) {
// 1. 库存扣减(可能抛出 ThirdPartyServiceException)
try {
stockClient.deductStock(req.getSkuId(), req.getQuantity());
} catch (Exception e) {
log.error("库存扣减失败 skuId={}", req.getSkuId(), e);
throw new ThirdPartyServiceException("StockService", -1, "库存服务不可用");
}
// 2. 创建订单
var order = Order.builder()
.userId(req.getUserId())
.skuId(req.getSkuId())
.quantity(req.getQuantity())
.amount(req.getAmount())
.status(OrderStatus.PENDING)
.build();
order = orderRepo.save(order);
// 3. 金额检查
if (order.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) {
throw new BusinessException(ResultCode.PARAM_RANGE_ERROR,
"订单金额必须大于0,当前: " + order.getAmount());
}
return toDTO(order);
}
public void cancelOrder(Long orderId) {
var order = orderRepo.findById(orderId)
.orElseThrow(() -> new BusinessException(ResultCode.ORDER_NOT_FOUND));
// 状态机校验
switch (order.getStatus()) {
case PENDING -> {
order.setStatus(OrderStatus.CANCELLED);
orderRepo.save(order);
}
case PAID -> {
// 已支付需要先退款
doRefund(order);
order.setStatus(OrderStatus.CANCELLED);
orderRepo.save(order);
}
case SHIPPED, COMPLETED -> {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR,
"当前状态=" + order.getStatus() + ", 不允许取消");
}
case CANCELLED -> {
throw new BusinessException(ResultCode.DUPLICATE_OPERATION, "订单已取消");
}
}
}
private void doRefund(Order order) {
try {
paymentClient.refund(order.getPaymentTransactionId(), order.getAmount());
} catch (Exception e) {
log.error("退款失败 orderId={}, txnId={}",
order.getId(), order.getPaymentTransactionId(), e);
// 退款失败不应该阻止取消操作,可以走异步重试
throw new ThirdPartyServiceException("PaymentService", -1, "退款服务暂时不可用");
}
}
private OrderDTO toDTO(Order order) {
return new OrderDTO(order.getId(), order.getUserId(), order.getSkuId(),
order.getQuantity(), order.getAmount(), order.getStatus());
}
}
5.3 DTO 参数校验
java
package com.example.order.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
/**
* 创建订单请求 DTO
*
* 配合 @Valid 使用,校验失败由全局异常处理器自动处理
*/
@Data
public class CreateOrderRequest {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotBlank(message = "商品SKU不能为空")
@Size(min = 1, max = 64, message = "SKU长度必须在1-64之间")
private String skuId;
@NotNull(message = "购买数量不能为空")
@Min(value = 1, message = "数量不能小于1")
@Max(value = 999, message = "单次购买不能超过999件")
private Integer quantity;
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额不能小于0.01元")
@DecimalMax(value = "999999.99", message = "单笔订单金额不能超过999999.99元")
private java.math.BigDecimal amount;
/** 收货地址 */
@NotBlank(message = "收货地址不能为空")
@Size(max = 256, message = "地址长度不能超过256字符")
private String address;
/** 备注(可选) */
@Size(max = 512, message = "备注不能超过512字符")
private String remark;
}
六、高级特性:异常日志与告警集成
6.1 结构化日志输出
java
package com.example.common.log;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/**
* 接口调用日志 AOP 切面
*
* 输出内容:
* - 请求路径、方法、参数(脱敏)
* - 响应状态码、耗时
* - 异常信息(如有)
* - traceId(用于链路关联)
*/
@Slf4j
@Aspect
@Component
public class ApiLogAspect {
private final ObjectMapper objectMapper;
@Around("@within(org.springframework.web.bind.annotation.RestController) && " +
"@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public Object aroundApiCall(ProceedingJoinPoint pjp) throws Throwable {
var start = Instant.now();
var methodName = pjp.getSignature().getName();
var className = pjp.getTarget().getClass().getSimpleName();
Map<String, Object> logData = new HashMap<>();
logData.put("class", className);
logData.put("method", methodName);
logData.put("timestamp", start.toString());
try {
var result = pjp.proceed();
var duration = Duration.between(start, Instant.now).toMillis();
logData.put("status", "SUCCESS");
logData.put("durationMs", duration);
log.info("[API] {}", objectMapper.writeValueAsString(logData));
return result;
} catch (Throwable e) {
var duration = Duration.between(start, Instant.now).toMillis();
logData.put("status", "ERROR");
logData.put("durationMs", duration);
logData.put("errorClass", e.getClass().getSimpleName());
logData.put("errorMessage", e.getMessage());
log.error("[API] {}", objectMapper.writeValueAsString(logData), e);
throw e;
}
}
}
6.2 异常告警(关键异常自动通知)
java
package com.example.common.alert;
import com.example.common.exception.ThirdPartyServiceException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.AroundThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 异常告警切面
*
* 触发条件:
* - 第三方服务异常(连续超过阈值次数)
* - 数据库异常
* - 任何 ERROR 级别异常(可选)
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ExceptionAlertAspect {
private final AlertService alertService;
@Value("${alert.enabled:true}")
private boolean alertEnabled;
/** 各类异常的计数器(滑动窗口) */
private final ConcurrentHashMap<String, SlidingWindowCounter> counters = new ConcurrentHashMap<>();
@AfterThrowing(pointcut = "execution(* com.example..service..*(..))", throwing = "e")
public void onServiceException(JoinPoint jp, Exception e) throws Throwable {
if (!alertEnabled) return;
var exceptionKey = e.getClass().getSimpleName();
var counter = counters.computeIfAbsent(exceptionKey, k ->
new SlidingWindowCounter(60, 10)); // 60秒窗口,阈值10次
if (counter.incrementAndGet() >= 10) {
// 连续60秒内同类异常超过10次 → 触发告警
alertService.sendAlert(AlertLevel.ERROR,
String.format("[%s] 频繁触发!最近60秒发生%d次\n位置: %s.%s\n最新错误: %s",
exceptionKey, counter.getCount(),
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName(),
e.getMessage()),
e);
counter.reset(); // 重置避免重复告警
}
}
}
record AlertLevel(String level) {
public static final AlertLevel INFO = new AlertLevel("info");
public static final AlertLevel WARN = new AlertLevel("warn");
public static final AlertLevel ERROR = new AlertLevel("error");
public static final AlertLevel FATAL = new AlertLevel("fatal");
}
七、与 OpenFeign / RestTemplate 的集成
微服务场景下,下游服务返回的错误需要被正确传播到上游:
7.1 Feign ErrorDecoder
java
package com.example.common.feign;
import com.example.common.exception.BusinessException;
import com.example.common.exception.ThirdPartyServiceException;
import com.example.common.response.Result;
import feign.Response;
import feign.codec.ErrorDecoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Feign 错误解码器
*
* 将下游服务返回的 Result<T> 错误响应转换为本地异常
*/
@Slf4j
@Component
public class FeignResultErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
try {
var body = response.body() != null ?
new String(response.body().asInputStream().readAllBytes()) : "{}";
// 尝试解析为统一响应格式
var result = parseResult(body);
if (result != null && result.getCode() != 200) {
// 下游业务异常 → 包装为 ThirdPartyServiceException
// 上游的全局异常处理器会将其转为 6xx 错误码返回给前端
return new ThirdPartyServiceException(
extractServiceName(methodKey),
result.getCode(),
result.getMessage()
);
}
} catch (IOException e) {
log.warn("[Feign] Failed to decode error response from {}", methodKey);
}
// 无法解析 → 返回默认 FeignException
return new Default().decode(methodKey, response);
}
private Result<?> parseResult(String body) {
try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.readValue(body, Result.class);
} catch (Exception e) {
return null;
}
}
private String extractServiceName(String methodKey) {
// methodKey 格式通常为: "IServiceName#method(params)"
var parts = methodKey.split("#");
return parts.length > 0 ? parts[0].replace("I", "") : "UnknownService";
}
}
八、常见踩坑记录
坑1:@ControllerAdvice 和 @RestControllerAdvice 搞混
现象:异常处理后前端收到的不是 JSON 而是 HTML 错误页面。
原因:用了 @ControllerAdvice 但没有加 @ResponseBody。在 RESTful 项目中应该始终用 @RestControllerAdvice(它等于 @ControllerAdvice + @ResponseBody)。
坑2:异常处理器之间的顺序问题
现象:BusinessException 被 Exception.class 的兜底处理器捕获了,导致返回了 500 而不是自定义错误码。
原因:Spring 选择 @ExceptionHandler 方法时取最精确匹配 。但如果两个方法都能匹配同一个异常,声明顺序不影响(Spring 会选参数类型最具体的)。确保你的自定义异常有专门的 handler 就行。
坑3:Filter 中抛出的异常无法被 @RestControllerAdvice 捕获
现象:JWT Filter 中校验失败抛出异常,但没有进入全局异常处理器。
原因:@RestControllerAdvice 只能拦截 Controller 层抛出的异常。Filter 在 DispatcherServlet 之前执行,它的异常不会被 Spring MVC 的异常处理机制接管。
解决:
java
// 方案一:在 Filter 中手动写入响应
catch (AuthenticationException e) {
var response = (HttpServletResponse) res;
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{"code":401,"message":"未登录","traceId":"%s"}""".formatted(TraceUtil.getTraceId()));
}
// 方案二:用 OncePerRequestFilter + HandlerExceptionResolver(更优雅)
// 或直接将认证逻辑放到 Spring Security 的 AuthenticationEntryPoint 中
坑4:@Valid 和 @Validated 的区别
@Valid (javax/validation) |
@Validated (springframework) |
|
|---|---|---|
| 来源 | JSR 303/380 | Spring 新增 |
| 支持分组 | ✅ | ✅ |
| 支持嵌套校验 | ✅ | ❌ |
| 用在方法参数上 | ❌ | ✅ |
| 推荐场景 | @RequestBody | @RequestParam / 方法级别 |
java
// @Valid 用于 @RequestBody(触发 MethodArgumentNotValidException)
@PostMapping
public Result<UserDTO> create(@Valid @RequestBody UserCreateRequest req) { ... }
// @Validated 用于 @RequestParam / 分组校验(触发 ConstraintViolationException)
@GetMapping
public Result<UserDTO> query(@Validated @NotBlank String username) { ... }
坑5:异常信息中包含敏感数据
现象:数据库连接字符串、API Key、用户密码出现在错误响应里。
原因:异常消息中拼接了不该暴露的信息,或者全局兜底处理器把整个 exception message 返回给了前端。
解决:
java
// 兜底处理器永远不要这样做:
@ExceptionHandler(Exception.class)
public Result<Void> handleAll(Exception e) {
return Result.fail(500, e.getMessage()); // ❌ 可能泄露敏感信息!
}
// 正确做法:
@ExceptionHandler(Exception.class)
public Result<Void> handleAll(Exception e) {
log.error("Unexpected error", e); // 详细日志写到服务端
return Result.fail(ResultCode.SYSTEM_ERROR); // 前端只看到通用提示
}
坑6:异步线程池中的异常丢失
现象:@Async 方法中抛出的异常没有被全局异常处理器捕获。
原因:异步方法的异常在线程池的工作线程中抛出,不在 Controller 调用链上。
解决:
java
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
// ★ 设置异常处理器
executor.setRejectedExecutionHandler((r, exec) -> {
log.error("Async task rejected: {}", r);
});
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async exception in {}.{}()",
method.getDeclaringClass().getSimpleName(), method.getName(), ex);
// 这里可以做额外的告警/补偿逻辑
};
}
}
本文基于 Spring Boot 3.3.x + Spring Security 6.x + Jakarta Validation 3.x 编写,涵盖了统一响应格式设计(Result泛型封装)、错误码枚举体系(编码规则/分类管理)、三层自定义异常(BusinessException/ValidationException/ThirdPartyServiceException)、全局异常处理器(@RestControllerAdvice + 8种异常类型的精细化处理)、AOP接口日志切面、异常告警机制、Feign错误解码器集成、以及 6 条生产环境真实踩坑经验(ControllerAdvice vs RestControllerAdvice/Filter异常丢失/@Valid vs @Validated/敏感数据泄露/异步异常)。这套方案已经在我们团队 5 个微服务项目中稳定运行超过一年,有问题欢迎评论区交流讨论。