SpringBoot统一异常管理方案

一、概述

  • 本文档提供了一套完整的统一异常处理和响应体封装机制,包含以下核心组件:
    • 统一响应体 (Result) - 用于接口响应的统一格式
    • 错误码接口 (ErrorCode) - 错误码标准接口
    • 认证授权错误码枚举 (AuthErrorCodeEnum) - 登录、认证相关的错误码
    • 业务异常类 (BusinessException) - 自定义业务异常基类
    • 全局异常处理器 (GlobalExceptionHandler) - 统一处理所有异常

1. 统一响应体 (Result)

  • 基本结构:
{
  "code": 200,           // 状态码
  "message": "操作成功",  // 响应消息
  "data": {},            // 响应数据(可选)
  "timestamp": 1234567890  // 时间戳
}
  • 使用示例:
// 成功响应(无数据)
return Result.success();

// 成功响应(有数据)
return Result.success(userInfoDTO);

// 成功响应(自定义消息)
return Result.success("查询成功", dataList);

// 失败响应(默认)
return Result.error();

// 失败响应(自定义消息)
return Result.error("操作失败");

// 失败响应(自定义状态码和消息)
return Result.error(400, "参数错误");

// 失败响应(使用错误码枚举)
return Result.error(AuthErrorCodeEnum.TOKEN_EXPIRED);

2. 错误码接口 (ErrorCode)

  • 所有错误码枚举都需要实现此自定义异常信息接口:
public interface ErrorCode {
    Integer getCode();
    String getMessage();
}

3. 业务异常类 (BusinessException)

  • 使用方式:
// 方式1: 仅使用消息
throw new BusinessException("用户不存在");

// 方式2: 使用状态码和消息
throw new BusinessException(400, "参数错误");

// 方式3: 使用错误码枚举
throw new BusinessException(AuthErrorCode.TOKEN_EXPIRED);

// 方式4: 使用错误码枚举和自定义消息
throw new BusinessException(AuthErrorCode.LOGIN_FAILED, "用户名或密码错误");

// 方式5: 使用消息和原始异常
throw new BusinessException("系统异常", e);

// 方式6: 使用完整参数
throw new BusinessException(500, "系统异常", e);

4. 全局异常处理器 (GlobalExceptionHandler)

  • 支持的异常类型:
异常类型 HTTP状态码 说明
BusinessException 200 业务异常
MethodArgumentNotValidException 400 参数校验异常(@Valid)
BindException 400 参数绑定异常
ConstraintViolationException 400 约束校验异常(@Validated)
MissingServletRequestParameterException 400 缺少请求参数
MethodArgumentTypeMismatchException 400 参数类型不匹配
HttpMessageNotReadableException 400 请求体不可读
HttpRequestMethodNotSupportedException 405 请求方法不支持
NoHandlerFoundException 404 404异常
MaxUploadSizeExceededException 400 文件上传超限
NullPointerException 500 空指针异常
IllegalArgumentException 400 非法参数异常
IllegalStateException 500 非法状态异常
RuntimeException 500 运行时异常
Exception 500 其他所有异常

5. 工作原理

  • 当发生异常时,@RestControllerAdvice 注解的 GlobalExceptionHandler 会自动捕获
  • 根据异常类型,调用对应的 @ExceptionHandler 方法
  • 方法返回统一的 Result 对象
  • 所有异常都会记录到日志中,便于排查问题

二、 状态码设计原则与使用规范

1. 为什么使用两套状态码?

  • 本项目采用了 HTTP状态码 + 响应体状态码 的双重状态码机制,这不是冗余设计,而是基于以下考虑:

  • 职责分离原则

    • HTTP状态码:表示HTTP协议层面的请求成功与否

    • 响应体状态码:表示业务逻辑层面的具体错误类型

  • 符合RESTful最佳实践

    • HTTP状态码是HTTP协议标准的一部分

    • 中间件、网关、代理可以根据HTTP状态码进行通用处理

    • 浏览器、CDN、负载均衡器等基础设施可以基于HTTP状态码优化行为

  • 便于监控和日志分析

    • HTTP状态码可以快速判断请求在传输层的成功与否

    • 响应体状态码可以精确识别具体的业务错误

  • 前端开发友好

    • HTTP状态码用于快速判断请求是否成功(如:401需要重新登录,403需要提示权限不足)

    • 响应体状态码用于处理具体的业务逻辑错误

2. 两套状态码的职责划分

  • HTTP状态码的职责
HTTP状态码 含义 适用场景
200 (OK) 请求成功处理 HTTP请求本身成功,无论业务结果如何
400 (Bad Request) 客户端请求错误 参数校验失败、参数格式错误、参数类型不匹配等
401 (Unauthorized) 未认证 缺少认证信息或认证信息无效
403 (Forbidden) 无权限 认证成功但权限不足
404 (Not Found) 资源不存在 请求的URL不存在
405 (Method Not Allowed) 请求方法不支持 使用了不正确的HTTP方法
500 (Internal Server Error) 服务端内部错误 系统异常、空指针异常等未预期的错误

核心原则:HTTP状态码表示请求在HTTP层面的处理结果,不是业务层面的结果。

  • 响应体状态码的职责
响应体状态码 含义 说明
200 业务成功 业务操作成功完成
400 参数错误 参数校验失败、参数格式错误等
404 资源不存在 请求的业务资源不存在
405 方法不支持 请求方法不支持
1001-1999 认证授权错误 未登录、令牌过期、权限不足等
2000-2999 用户相关错误 用户不存在、用户已存在等
3000-3999 订单相关错误 订单不存在、订单状态错误等
4000-4999 支付相关错误 支付失败、支付超时等
5000-9999 其他业务错误 其他自定义业务场景

核心原则:响应体状态码表示业务逻辑层面的具体错误类型,便于前端精确处理。

3. 具体使用规则

  • 业务异常 (BusinessException)
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.OK)
public Result<?> handleBusinessException(BusinessException e, HttpServletRequest request) {
    return Result.error(e.getCode(), e.getMessage());
}
  • 为什么使用 HTTP 200?

    • 业务异常是预期的、可控的业务逻辑结果

    • HTTP请求本身是成功的(服务器正常接收并处理了请求)

    • 例如:用户不存在、余额不足、库存不足等,这些都是正常的业务场景

    • 使用 HTTP 200 不会误导中间件和监控系统

  • 响应体状态码

    • 使用自定义的业务错误码(如 1001、1002 等)

    • 前端根据响应体状态码判断具体的业务错误

    • 示例
// HTTP Status: 200
{
  "code": 1012,
  "message": "用户名不存在",
  "data": null,
  "timestamp": 1706107291000
}
  • 参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
    return Result.error(400, errorMessage);
}
  • 为什么使用 HTTP 400?

    • 参数错误是客户端的错误,不是服务端的错误

    • 客户端需要修正请求参数后重新请求

    • HTTP 400 明确告知中间件和代理这是客户端错误

    • 便于CDN、负载均衡器等进行错误统计和监控

  • 响应体状态码

    • 使用 400 表示参数错误

    • 与HTTP状态码保持一致,便于理解

    • 示例
// HTTP Status: 400
{
  "code": 400,
  "message": "用户名不能为空; 密码长度必须在6-20位之间",
  "data": null,
  "timestamp": 1706107291000
}
  • 系统异常
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<?> handleException(Exception e, HttpServletRequest request) {
    return Result.error(500, "系统内部错误,请联系管理员");
}
  • 为什么使用 HTTP 500?

    • 系统异常是未预期的、服务端的错误

    • 表示服务器内部发生了异常情况

    • 需要运维人员介入处理

    • 监控系统可以基于 HTTP 500 触发告警

  • 响应体状态码

    • 使用 500 表示系统错误

    • 前端可以统一处理所有 500 错误,显示友好的错误提示

    • 示例
// HTTP Status: 500
{
  "code": 500,
  "message": "系统内部错误,请联系管理员",
  "data": null,
  "timestamp": 1706107291000
}

4. 设计的合理性和优势

  • 优势1:符合RESTful规范

    • HTTP状态码是HTTP协议的一部分,遵循标准可以让系统更容易被理解和集成。例如:
    • 网关/中间件:可以根据HTTP状态码进行通用处理

      • 401 → 跳转到登录页
      • 403 → 返回权限不足提示
      • 500 → 触发告警通知
    • CDN/缓存

      • 只缓存 200 响应
      • 不缓存 4xx 和 5xx 响应
    • 负载均衡器

      • 根据 500 比例判断后端健康状态
      • 自动摘除不健康的实例
  • 优势2:便于监控和运维

    • 监控系统可以基于HTTP状态码进行统计分析:
# Prometheus 示例
- alert: HighErrorRate
  expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
  annotations:
    summary: "服务端错误率过高"
  • 优势3:前后端开发友好

    • 前端开发

      // 基于 HTTP 状态码的快速判断
      if (response.status === 401) {
        // 跳转到登录页
        redirectToLogin();
      } else if (response.status === 403) {
        // 提示权限不足
        showMessage('权限不足');
      } else {
        // 解析响应体中的业务状态码
        const { code, message } = response.data;
        if (code === 1012) {
          showMessage('用户名不存在');
        }
      }
      
    • 后端开发

      // 代码简洁,职责清晰
      throw new BusinessException(AuthErrorCodeEnum.USERNAME_NOT_EXIST);
      // 不需要关心 HTTP 状态码,由全局异常处理器统一处理
      
  • 优势4:降低误判风险

    • 如果统一使用 HTTP 200:

      // 不推荐的方案
      // HTTP Status: 200 (所有请求都是200)
      {
        "code": 500,
        "message": "空指针异常"
      }
      
    • 问题

      • 监控系统无法准确识别服务端错误

      • 网关无法基于状态码进行重试或熔断

      • CDN 无法区分成功和失败响应,可能导致错误内容被缓存

      • 无法快速定位问题(所有请求都显示200)

5. 常见疑问解答

Q1: 为什么不统一使用 HTTP 200,只看响应体状态码?

  • A: 不推荐,原因如下:

  • 违反RESTful规范:HTTP状态码有明确的语义,应该正确使用

  • 监控系统失效:无法准确统计服务端错误率

  • 中间件无法工作:网关、代理等无法基于状态码做通用处理

  • 缓存策略失效:CDN可能缓存错误响应

  • 问题定位困难:所有请求都显示200,无法快速识别失败请求

Q2: BusinessException 为什么返回 HTTP 200?

  • A: 因为业务异常是预期的业务逻辑结果,不是系统错误:

    • 用户不存在 → 正常业务场景,不是错误

    • 余额不足 → 正常业务场景,不是错误

    • 库存不足 → 正常业务场景,不是错误

    • 这些情况下,HTTP请求本身是成功的,服务器正常处理了请求,只是业务逻辑返回了一个特定的结果。
  • 类比

    • 就像银行查询余额,余额为0是一个正常的业务结果,不是系统错误

    • 就像购物车结算,库存不足是一个正常的业务结果,不是系统错误

Q3: 参数校验为什么要用 HTTP 400?

  • A: 因为参数错误是客户端的错误,需要客户端修正:

    • 参数格式错误 → 客户端需要修正参数格式

    • 参数类型错误 → 客户端需要修正参数类型

    • 必填参数缺失 → 客户端需要补充必填参数

    • 使用 HTTP 400 可以明确告知客户端这是客户端的错误,需要客户端修正后重新请求。

Q4: 这样不会让前端代码变复杂吗?

  • A: 不会,反而更清晰:

    // 推荐的处理方式
    axios.get('/api/user/123')
      .then(response => {
        // 这里只处理 HTTP 200 的响应
        const { code, message, data } = response.data;
        
        if (code === 200) {
          // 业务成功
          renderData(data);
        } else {
          // 业务失败(如用户不存在、余额不足等)
          showMessage(message);
        }
      })
      .catch(error => {
        // 这里统一处理非 200 的 HTTP 响应
        if (error.response.status === 401) {
          // 跳转到登录页
          redirectToLogin();
        } else if (error.response.status === 403) {
          // 提示权限不足
          showMessage('权限不足');
        } else if (error.response.status === 400) {
          // 参数错误
          showMessage(error.response.data.message);
        } else if (error.response.status >= 500) {
          // 系统错误
          showMessage('系统错误,请稍后重试');
        }
      });
    
  • 优势

    • HTTP状态码用于快速判断请求成功与否和错误类型

    • 响应体状态码用于处理具体的业务逻辑错误

    • 职责清晰,代码可维护性强

Q5: 如果我想统一处理所有错误怎么办?

  • A: 可以在 axios 拦截器中统一处理:

    // 响应拦截器
    axios.interceptors.response.use(
      response => {
        // HTTP 200 响应
        const { code, message } = response.data;
        
        // 业务错误统一处理
        if (code !== 200) {
          showMessage(message);
          return Promise.reject(new Error(message));
        }
        
        return response;
      },
      error => {
        // 非 HTTP 200 响应
        const { status, data } = error.response;
        
        if (status === 401) {
          redirectToLogin();
        } else if (status === 403) {
          showMessage('权限不足');
        } else if (status >= 500) {
          showMessage('系统错误,请稍后重试');
        }
        
        return Promise.reject(error);
      }
    );
    

6. 最佳实践建议

  • 严格遵守状态码映射规则

    • 业务异常 → HTTP 200 + 业务错误码

    • 参数错误 → HTTP 400 + 400

    • 系统异常 → HTTP 500 + 500

  • 不要随意修改HTTP状态码

    • 不要为了省事统一使用 HTTP 200

    • 不要随意将参数错误改为 HTTP 500

  • 前端要正确处理两套状态码

    • 先判断 HTTP 状态码

    • 再判断响应体状态码

  • 文档要清晰说明

    • 在API文档中明确说明HTTP状态码和响应体状态码的含义

    • 提供完整的错误码列表

  • 监控要区分两套状态码

    • 基于 HTTP 状态码监控服务端健康状态

    • 基于响应体状态码监控业务错误

7. 错误码分类

  • 认证相关 (1001-1099)

    • UNAUTHORIZED(1001) - 未认证,请先登录

    • TOKEN_MISSING(1002) - 认证令牌缺失

    • TOKEN_INVALID(1003) - 认证令牌无效

    • TOKEN_EXPIRED(1004) - 认证令牌已过期

    • LOGIN_FAILED(1006) - 登录失败,用户名或密码错误

    • LOGIN_DISABLED(1007) - 账号已被禁用

    • LOGIN_LOCKED(1008) - 账号已被锁定

    • USERNAME_NOT_EXIST(1012) - 用户名不存在

    • USER_NOT_EXIST(1013) - 用户不存在

    • CAPTCHA_ERROR(1014) - 验证码错误

  • 授权相关 (1101-1199)

    • FORBIDDEN(1101) - 无权限访问

    • PERMISSION_DENIED(1104) - 权限不足

    • RESOURCE_ACCESS_DENIED(1106) - 资源访问拒绝

  • 用户相关 (1201-1299)

    • USER_ALREADY_EXIST(1201) - 用户已存在

    • PHONE_DUPLICATE(1203) - 手机号已被使用

  • 令牌相关 (1301-1399)

    • TOKEN_PARSE_ERROR(1301) - 令牌解析失败

    • TOKEN_SIGNATURE_ERROR(1302) - 令牌签名错误

三、核心使用示例

1. Controller层 - 参数校验

@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {
    
    private final UserService userService;
    
    /**
     * 用户登录
     */
    @PostMapping("/login")
    public Result<UserInfoDTO> login(@Valid @RequestBody LoginRequest request) {
        UserInfoDTO userInfoDTO = userService.login(request);
        return Result.success("登录成功", userInfoDTO);
    }
    
    /**
     * 获取用户信息
     */
    @GetMapping("/info/{id}")
    public Result<UserInfoDTO> getUserInfo(@PathVariable Long id) {
        UserInfoDTO userInfoDTO = userService.getUserInfo(id);
        return Result.success(userInfoDTO);
    }
}

2. Service层 - 异常抛出

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
    
    private final UserRepository userRepository;
    
    @Override
    public UserInfoDTO login(LoginRequest request) {
        // 查询用户
        User user = userRepository.findByUsername(request.getUsername());
        if (user == null) {
            throw new BusinessException(AuthErrorCodeEnum.USERNAME_NOT_EXIST);
        }
        
        // 校验状态
        if (!user.isEnabled()) {
            throw new BusinessException(AuthErrorCodeEnum.LOGIN_DISABLED);
        }
        
        if (user.isLocked()) {
            throw new BusinessException(AuthErrorCodeEnum.LOGIN_LOCKED);
        }
        
        log.info("用户登录成功: username={}", user.getUsername());
        
        return UserInfoDTO.builder()
                .id(user.getId())
                .username(user.getUsername())
                .build();
    }
    
    @Override
    public UserInfoDTO getUserInfo(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new BusinessException(AuthErrorCodeEnum.USER_NOT_EXIST));
        return convertToDTO(user);
    }
}

3. DTO参数校验

/**
 * 登录请求
 */
@Data
public class LoginRequest {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在3-20位之间")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
    private String password;
    
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
}

4. 嵌套对象校验

@Data
public class UserUpdateRequest {
    
    @NotNull(message = "用户ID不能为空")
    private Long id;
    
    @Size(max = 50, message = "昵称长度不能超过50位")
    private String nickname;
    
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @Valid
    @NotNull(message = "地址信息不能为空")
    private AddressInfo address;
}

@Data
public class AddressInfo {
    
    @NotBlank(message = "省份不能为空")
    private String province;
    
    @NotBlank(message = "城市不能为空")
    private String city;
    
    @Size(max = 200, message = "详细地址长度不能超过200位")
    private String detail;
}

5. 自定义错误码指南

  • 错误码分类规范建议
模块 错误码范围 说明
认证授权 1001-1499 登录、权限、令牌相关
用户管理 1501-1599 用户增删改查相关
订单管理 2001-2099 订单业务相关
商品管理 2101-2199 商品业务相关
支付相关 3001-3099 支付业务相关
通用业务 4001-4999 其他业务场景

四、跨项目复用指南

1. 必需的Maven依赖

<!--Lombok依赖-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!-- Spring Validation -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2. 核心组件完整代码

  • ErrorCode.java
package cn.cloud.common.exception;

/**
 * 错误码接口
 * 所有错误码枚举类都需要实现此接口
 * @author cloud
 * {@code @date} 2026-01-21
 */
public interface ErrorCode {

    /**
     * 获取错误码
     */
    Integer getCode();

    /**
     * 获取错误信息
     */
    String getMessage();
}

Result.java

package cn.cloud.common.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import cn.cloud.common.exception.ErrorCode;

import java.io.Serial;
import java.io.Serializable;

/**
 * 统一响应结果封装类
 * @param <T> 数据类型
 * @author cloud
 * {@code @date} 2026-01-21
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 响应状态码
     */
    private Integer code;

    /**
     * 响应消息
     */
    private String message;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 时间戳
     */
    private final Long timestamp;

    public Result() {
        this.timestamp = System.currentTimeMillis();
    }

    public Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
        this.timestamp = System.currentTimeMillis();
    }

    public Result(Integer code, String message) {
        this.code = code;
        this.message = message;
        this.timestamp = System.currentTimeMillis();
    }

    /**
     * 成功响应(无数据)
     */
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功");
    }

    /**
     * 成功响应(有数据)
     */
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }

    /**
     * 成功响应(自定义消息)
     */
    public static <T> Result<T> success(String message, T data) {
        return new Result<>(200, message, data);
    }

    /**
     * 成功响应(自定义消息,无数据)
     */
    public static <T> Result<T> success(String message) {
        return new Result<>(200, message);
    }

    /**
     * 失败响应
     */
    public static <T> Result<T> error() {
        return new Result<>(500, "操作失败");
    }

    /**
     * 失败响应(自定义消息)
     */
    public static <T> Result<T> error(String message) {
        return new Result<>(500, message);
    }

    /**
     * 失败响应(自定义状态码和消息)
     */
    public static <T> Result<T> error(Integer code, String message) {
        return new Result<>(code, message);
    }

    /**
     * 失败响应(使用错误码枚举)
     */
    public static <T> Result<T> error(ErrorCode errorCode) {
        return new Result<>(errorCode.getCode(), errorCode.getMessage());
    }

    @Override
    public String toString() {
        return "Result{" +
                "code=" + code +
                ", message='" + message + '\'' +
                ", data=" + data +
                ", timestamp=" + timestamp +
                '}';
    }
}

BusinessException.java

package cn.cloud.common.exception;

import lombok.Getter;

import java.io.Serial;

/**
 * 业务异常基类
 * 
 * @author cloud
 * {@code @date} 2026-01-21
 */
@Getter
public class BusinessException extends RuntimeException {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 错误码
     */
    private final Integer code;

    /**
     * 错误信息
     */
    private final String message;

    public BusinessException(String message) {
        super(message);
        this.code = 500;
        this.message = message;
    }

    public BusinessException(Integer code, String message) {
        super(message);
        this.code = code;
        this.message = message;
    }

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
    }

    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.code = errorCode.getCode();
        this.message = message;
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
        this.code = 500;
        this.message = message;
    }

    public BusinessException(Integer code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
        this.message = message;
    }
}

GlobalExceptionHandler.java

package cn.cloud.common.exception;

import cn.cloud.common.response.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.util.stream.Collectors;

/**
 * 全局异常处理器
 * @author cloud
 * {@code @date} 2026-01-21
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.OK)
    public Result<?> handleBusinessException(BusinessException e, HttpServletRequest request) {
        log.error("业务异常: URI={}, Code={}, Message={}", request.getRequestURI(), e.getCode(), e.getMessage(), e);
        return Result.error(e.getCode(), e.getMessage());
    }

    /**
     * 处理参数校验异常(@Valid 注解)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
        String errorMessage = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        log.error("参数校验异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(400, errorMessage);
    }

    /**
     * 处理参数绑定异常
     */
    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleBindException(BindException e, HttpServletRequest request) {
        String errorMessage = e.getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        log.error("参数绑定异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(400, errorMessage);
    }

    /**
     * 处理参数校验异常(@Validated 注解)
     */
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleConstraintViolationException(ConstraintViolationException e, HttpServletRequest request) {
        String errorMessage = e.getConstraintViolations().stream()
                .map(ConstraintViolation::getMessage)
                .collect(Collectors.joining("; "));
        log.error("约束校验异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(400, errorMessage);
    }

    /**
     * 处理缺少请求参数异常
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMissingServletRequestParameterException(MissingServletRequestParameterException e, HttpServletRequest request) {
        String errorMessage = String.format("缺少必需的请求参数: %s", e.getParameterName());
        log.error("缺少请求参数异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(400, errorMessage);
    }

    /**
     * 处理参数类型不匹配异常
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
        String errorMessage = String.format("参数类型不匹配: %s", e.getName());
        log.error("参数类型不匹配异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(400, errorMessage);
    }

    /**
     * 处理请求体不可读异常
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {
        log.error("请求体不可读异常: URI={}, Message={}", request.getRequestURI(), e.getMessage());
        return Result.error(400, "请求体格式错误或不可读");
    }

    /**
     * 处理请求方法不支持异常
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    public Result<?> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
        String errorMessage = String.format("不支持的请求方法: %s", e.getMethod());
        log.error("请求方法不支持异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(405, errorMessage);
    }

    /**
     * 处理404异常
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Result<?> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
        String errorMessage = String.format("请求的资源不存在: %s %s", e.getHttpMethod(), e.getRequestURL());
        log.error("404异常: URI={}, Message={}", request.getRequestURI(), errorMessage);
        return Result.error(404, "请求的资源不存在");
    }

    /**
     * 处理文件上传大小超出限制异常
     */
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMaxUploadSizeExceededException(HttpServletRequest request) {
        log.error("文件上传大小超出限制: URI={}", request.getRequestURI());
        return Result.error(400, "上传文件大小超出限制");
    }

    /**
     * 处理空指针异常
     */
    @ExceptionHandler(NullPointerException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleNullPointerException(NullPointerException e, HttpServletRequest request) {
        log.error("空指针异常: URI={}", request.getRequestURI(), e);
        return Result.error(500, "系统内部错误: 空指针异常");
    }

    /**
     * 处理非法参数异常
     */
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
        log.error("非法参数异常: URI={}, Message={}", request.getRequestURI(), e.getMessage());
        return Result.error(400, e.getMessage());
    }

    /**
     * 处理非法状态异常
     */
    @ExceptionHandler(IllegalStateException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleIllegalStateException(IllegalStateException e, HttpServletRequest request) {
        log.error("非法状态异常: URI={}, Message={}", request.getRequestURI(), e.getMessage());
        return Result.error(500, e.getMessage());
    }

    /**
     * 处理运行时异常
     */
    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
        log.error("运行时异常: URI={}, Message={}", request.getRequestURI(), e.getMessage(), e);
        return Result.error(500, "系统运行时异常: " + e.getMessage());
    }

    /**
     * 处理其他所有异常
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleException(Exception e, HttpServletRequest request) {
        log.error("系统异常: URI={}", request.getRequestURI(), e);
        return Result.error(500, "系统内部错误,请联系管理员");
    }
}

AuthErrorCodeEnum.java

package cn.cloud.common.enums;

import cn.cloud.common.exception.ErrorCode;

/**
 * 认证授权相关错误码枚举
 * 
 * @author cloud
 * {@code @date} 2026-01-21
 */
public enum AuthErrorCodeEnum implements ErrorCode {

    // ========== 认证相关 (1001-1099) ==========
    UNAUTHORIZED(1001, "未认证,请先登录"),
    TOKEN_MISSING(1002, "认证令牌缺失"),
    TOKEN_INVALID(1003, "认证令牌无效"),
    TOKEN_EXPIRED(1004, "认证令牌已过期"),
    TOKEN_REFRESH_FAILED(1005, "令牌刷新失败"),
    LOGIN_FAILED(1006, "登录失败,用户名或密码错误"),
    LOGIN_DISABLED(1007, "账号已被禁用"),
    LOGIN_LOCKED(1008, "账号已被锁定"),
    LOGIN_EXPIRED(1009, "账号已过期"),
    PASSWORD_ERROR(1010, "密码错误"),
    PASSWORD_EXPIRED(1011, "密码已过期,请修改密码"),
    USERNAME_NOT_EXIST(1012, "用户名不存在"),
    USER_NOT_EXIST(1013, "用户不存在"),
    CAPTCHA_ERROR(1014, "验证码错误"),
    CAPTCHA_EXPIRED(1015, "验证码已过期"),
    CAPTCHA_RATE_LIMIT_EXCEEDED(1016, "验证码请求过于频繁,请稍后再试"),
    LOGIN_TIMEOUT(1017, "登录超时"),
    TOO_MANY_LOGIN_ATTEMPTS(1018, "登录尝试次数过多,请稍后再试"),
    DEVICE_NOT_TRUSTED(1019, "设备未受信任"),
    IP_NOT_ALLOWED(1020, "IP地址不在允许范围内"),
    SESSION_INVALID(1021, "会话无效"),
    SESSION_EXPIRED(1022, "会话已过期"),

    // ========== 授权相关 (1101-1199) ==========
    FORBIDDEN(1101, "无权限访问"),
    ROLE_NOT_EXIST(1102, "角色不存在"),
    ROLE_DISABLED(1103, "角色已被禁用"),
    PERMISSION_DENIED(1104, "权限不足"),
    RESOURCE_NOT_FOUND(1105, "资源不存在"),
    RESOURCE_ACCESS_DENIED(1106, "资源访问拒绝"),
    OPERATION_NOT_ALLOWED(1107, "不允许执行此操作"),
    DATA_ACCESS_DENIED(1108, "数据访问权限不足"),
    CROSS_TENANT_ACCESS(1109, "跨租户访问被拒绝"),

    // ========== 用户相关 (1201-1299) ==========
    USER_ALREADY_EXIST(1201, "用户已存在"),
    USER_NAME_DUPLICATE(1202, "用户名已存在"),
    PHONE_DUPLICATE(1203, "手机号已被使用"),
    EMAIL_DUPLICATE(1204, "邮箱已被使用"),
    USER_INFO_INCOMPLETE(1205, "用户信息不完整"),
    USER_STATUS_ABNORMAL(1206, "用户状态异常"),

    // ========== 令牌相关 (1301-1399) ==========
    TOKEN_PARSE_ERROR(1301, "令牌解析失败"),
    TOKEN_SIGNATURE_ERROR(1302, "令牌签名错误"),
    TOKEN_MALFORMED(1303, "令牌格式错误"),
    TOKEN_UNSUPPORTED(1304, "不支持的令牌类型"),
    REFRESH_TOKEN_INVALID(1305, "刷新令牌无效"),
    REFRESH_TOKEN_EXPIRED(1306, "刷新令牌已过期"),

    // ========== 其他认证相关 (1401-1499) ==========
    AUTHENTICATION_FAILED(1401, "认证失败"),
    AUTHENTICATION_TIMEOUT(1402, "认证超时"),
    AUTHENTICATION_INTERRUPTED(1403, "认证被中断"),
    AUTHENTICATION_ERROR(1404, "认证发生错误"),
    SSO_AUTH_FAILED(1405, "单点登录认证失败"),
    SSO_TOKEN_INVALID(1406, "单点登录令牌无效"),
    SSO_SERVER_ERROR(1407, "单点登录服务异常"),
    SYSTEM_ERROR(1408, "系统错误");

    /**
     * 错误码
     */
    private final Integer code;
    /**
     * 错误信息
     */
    private final String message;

    AuthErrorCodeEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public Integer getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

3. 初始化注意事项

  1. 包名替换:将所有文件中的包名 cn.cloud 替换为你项目的包名
  2. 扫描路径:确保 @SpringBootApplication@ComponentScan 能扫描到这些组件
  3. 错误码规划:根据业务规划错误码范围,避免与现有错误码冲突
  4. 日志级别:生产环境建议将异常处理器日志级别设置为 WARNERROR

4. 常见问题 (FAQ)

Q1: 如何添加新的错误码枚举?

A: 参照 AuthErrorCodeEnum 创建新枚举类,实现 ErrorCode 接口:

public enum YourBusinessErrorCode implements ErrorCode {
    
    ERROR_CODE(3001, "错误描述"),
    ANOTHER_ERROR(3002, "另一个错误");
    
    private final Integer code;
    private final String message;
    
    YourBusinessErrorCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
    
    @Override
    public Integer getCode() {
        return code;
    }
    
    @Override
    public String getMessage() {
        return message;
    }
}

Q2: 如何自定义异常处理逻辑?

A:GlobalExceptionHandler 中添加新的异常处理方法:

@ExceptionHandler(CustomException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleCustomException(CustomException e, HttpServletRequest request) {
    log.error("自定义异常: URI={}, Message={}", request.getRequestURI(), e.getMessage());
    return Result.error(400, e.getMessage());
}

Q3: 生产环境如何避免敏感信息泄露?

A: 配置日志级别:

# application-prod.yml
logging:
  level:
    com.yourproject.common.exception: error

或在 GlobalExceptionHandler 中根据环境返回不同的错误消息:

@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e, HttpServletRequest request) {
    String message = isProd() ? "系统内部错误" : e.getMessage();
    log.error("系统异常: URI={}", request.getRequestURI(), e);
    return Result.error(500, message);
}

Q4: 嵌套对象的参数校验不生效?

A: 确保在嵌套对象字段上添加 @Valid 注解:

@Data
public class ParentRequest {
    @Valid  // 必须添加
    @NotNull
    private ChildInfo child;
}

@Data
public class ChildInfo {
    @NotBlank
    private String name;
}

五、最佳实践

  • 使用错误码枚举:优先使用预定义的错误码枚举,保持错误信息的一致性

  • 异常链传递:在捕获异常重新抛出时,保留原始异常信息

  • 详细日志:全局异常处理器会自动记录异常日志,包含请求URI等上下文信息

  • 参数校验:使用 @Valid@Validated 进行参数校验,避免手动校验

  • 统一响应:所有接口返回值使用 Result 包装,确保响应格式统一

六、注意事项

  • 确保已添加 Lombok 依赖(已在 pom.xml 中配置)

  • 确保已添加 Spring Validation 依赖(已在 pom.xml 中配置)

  • 如需使用 Spring Security 相关异常处理,请添加 Spring Security 依赖

  • 生产环境中,建议配置日志级别,避免敏感信息泄露

posted @ 2026-01-24 15:58  flycloudy  阅读(1)  评论(0)    收藏  举报