Spring Boot 3 全局异常处理与统一响应封装进阶实战

Spring Boot 3 全局异常处理与统一响应封装进阶实战

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:异常处理器之间的顺序问题

现象:BusinessExceptionException.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 个微服务项目中稳定运行超过一年,有问题欢迎评论区交流讨论。

相关推荐
明月_清风1 小时前
Foundry Fuzz Testing:让测试自动寻找 Solidity Bug
后端·web3·solidity
橘子汽水1681 小时前
Leetcode 208,207实现Trie前缀树,课程表
java·数据结构·算法·leetcode
IT_陈寒1 小时前
SpringBoot自动配置失效时我差点把电脑扔了
前端·人工智能·后端
tryxr1 小时前
Chat2Excel 文件服务模块剩余功能开发
java·服务器·windows·java项目·文件服务
张小姐的猫1 小时前
【AI大模型接入SDK】 —— Ollama本地接入Deepseek
java·linux·开发语言·网络·c++·人工智能
谢亮_vipxieliang1 小时前
ValidX与Maven/Gradle集成配置指南
java·spring boot·spring·maven·hibernate
Nuanyt1 小时前
JUC常见核心知识梳理01 线程 并发 JMM volatile 管程 锁 synchronized
java·开发语言·网络·jvm
我命由我123451 小时前
Android 控件 - ListAdapter
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
CodeStats1 小时前
【Java 类加载器】Java 类加载器完整体系深度拆解(中):URLClassLoader 能力剖析与继承委派辨析
java·jvm·classloader·类加载器