前言
前面我们已经完成了 JWT 登录认证和 Redis 验证码登录,这两个功能都属于比较典型的业务接口开发。
但是在真实项目里面,只会写正常流程还不够。比如登录时密码错误、验证码错误、用户不存在、参数为空、数据库操作异常,这些情况都需要给前端返回明确的错误信息。
如果每个 Controller 里面都写一堆 try…catch,代码会变得非常乱。所以这一篇我们就来学习一个项目里很常见、也很实用的功能:统一异常处理和业务异常封装。
本篇主要实现下面几个内容:
一、为什么需要统一异常处理
先看一种比较常见的写法:
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
try {
String token = userService.login(loginDTO);
return Result.success(token);
} catch (Exception e) {
return Result.error("登录失败");
}
}
这种写法虽然能跑,但是问题很明显:
所以更推荐的方式是:
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
String token = userService.login(loginDTO);
return Result.success(token);
}
如果业务失败,就在 Service 层抛出异常:
throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
然后交给全局异常处理器统一返回。
二、准备统一返回结果类
如果项目里已经有 Result 类,可以直接复用。这里给一个比较常见的写法。
package com.example.common;
import lombok.Data;
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}
文字说明
这个 Result 类主要用于统一接口返回格式。
正常返回:
{
"code": 200,
"message": "操作成功",
"data": {}
}
异常返回:
{
"code": 4001,
"message": "账号或密码错误",
"data": null
}
这样前端只需要统一判断 code,就可以知道接口是否成功。
三、定义错误码枚举
项目里面如果直接到处写字符串,比如:
throw new BusinessException("账号不存在");
throw new BusinessException("验证码错误");
throw new BusinessException("密码错误");
短期看没问题,但后期接口多了以后,错误信息会越来越分散。
所以我们可以单独定义一个错误码枚举。
package com.example.common;
import lombok.Getter;
@Getter
public enum ResultCode {
SUCCESS(200, "操作成功"),
PARAM_ERROR(400, "参数错误"),
UNAUTHORIZED(401, "用户未登录或登录已过期"),
FORBIDDEN(403, "没有权限访问"),
NOT_FOUND(404, "资源不存在"),
SYSTEM_ERROR(500, "系统异常,请稍后再试"),
USER_NOT_FOUND(1001, "用户不存在"),
USER_PASSWORD_ERROR(1002, "账号或密码错误"),
USER_DISABLED(1003, "用户已被禁用"),
CAPTCHA_ERROR(2001, "验证码错误或已过期"),
TOKEN_INVALID(3001, "Token 无效或已过期");
private final Integer code;
private final String message;
ResultCode(Integer code, String message) {
this.code = code;
this.message = message;
}
}
文字说明
这里使用枚举统一管理错误码和错误信息。
比如:
ResultCode.USER_PASSWORD_ERROR
表示账号或密码错误。
这样写的好处是:
四、自定义业务异常 BusinessException
接下来定义一个业务异常类。
业务异常和系统异常不一样。
业务异常一般是我们主动抛出的,比如:
这些不是程序崩了,而是业务规则不满足。
package com.example.exception;
import com.example.common.ResultCode;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
private final String message;
public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
this.message = resultCode.getMessage();
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
this.message = message;
}
public BusinessException(String message) {
super(message);
this.code = 500;
this.message = message;
}
}
文字说明
这里让 BusinessException 继承 RuntimeException。
原因很简单:
比如后面可以这样写:
throw new BusinessException(ResultCode.CAPTCHA_ERROR);
代码含义非常清楚:当前业务失败,原因是验证码错误。
五、编写全局异常处理器
统一异常处理的核心就是 @RestControllerAdvice。
package com.example.exception;
import com.example.common.Result;
import com.example.common.ResultCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常:{}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldError() == null
? "参数校验失败"
: e.getBindingResult().getFieldError().getDefaultMessage();
log.warn("请求体参数校验异常:{}", message);
return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
}
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldError() == null
? "参数绑定失败"
: e.getBindingResult().getFieldError().getDefaultMessage();
log.warn("请求参数绑定异常:{}", message);
return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常:", e);
return Result.error(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMessage());
}
}
文字说明
这个类是全局异常处理的核心。
@RestControllerAdvice 可以理解为 Controller 的全局增强。只要 Controller 执行过程中抛出了异常,就可以被这里统一捕获。
这里分别处理了几类异常:
这是我们自己定义的业务异常,比如登录失败、验证码错误、用户不存在等。
这个异常通常出现在 @RequestBody 配合参数校验时。
这个异常通常出现在普通请求参数绑定失败时。
这是兜底异常,防止系统出现未知异常时直接把错误堆栈返回给前端。
这里有一个很重要的点:系统异常不要直接把 e.getMessage() 返回给前端。
因为系统异常里可能包含数据库字段、SQL、路径、服务信息等,不适合暴露给用户。
六、在登录业务中使用业务异常
下面以登录功能为例,看看业务异常应该放在哪里。
1. Controller 层
package com.example.controller;
import com.example.common.Result;
import com.example.dto.LoginDTO;
import com.example.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
String token = userService.login(loginDTO);
return Result.success(token);
}
}
文字说明
Controller 层只负责三件事:
它不需要写大量 try…catch。
如果登录失败,Service 会抛出业务异常,然后交给全局异常处理器处理。
2. Service 层接口
package com.example.service;
import com.example.dto.LoginDTO;
public interface UserService {
String login(LoginDTO loginDTO);
}
3. Service 实现类
package com.example.service.impl;
import com.example.common.ResultCode;
import com.example.dto.LoginDTO;
import com.example.entity.User;
import com.example.exception.BusinessException;
import com.example.mapper.UserMapper;
import com.example.service.UserService;
import com.example.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
public String login(LoginDTO loginDTO) {
if (loginDTO == null
|| !StringUtils.hasText(loginDTO.getUsername())
|| !StringUtils.hasText(loginDTO.getPassword())) {
throw new BusinessException(ResultCode.PARAM_ERROR);
}
User user = userMapper.selectByUsername(loginDTO.getUsername());
if (user == null) {
throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
}
if (!loginDTO.getPassword().equals(user.getPassword())) {
throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
}
if (user.getStatus() != null && user.getStatus() == 0) {
throw new BusinessException(ResultCode.USER_DISABLED);
}
return JwtUtil.generateToken(user.getId(), user.getUsername());
}
}
文字说明
这里的业务判断都放在 Service 层。
比如:
if (user == null) {
throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
}
用户不存在时,不直接返回 null,而是抛出一个业务异常。
再比如:
if (!loginDTO.getPassword().equals(user.getPassword())) {
throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
}
密码错误也抛出同一个错误。
这里故意把“用户不存在”和“密码错误”都返回成“账号或密码错误”,是为了避免接口暴露过多账号信息。实际开发中,这也是比较常见的安全处理。
4. Mapper 层
package com.example.mapper;
import com.example.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("select id, username, password, status from user where username = #{username}")
User selectByUsername(String username);
}
文字说明
Mapper 层只负责查询数据库。
这里根据用户名查询用户信息,然后交给 Service 层判断业务规则。
也就是说:
这样整体结构会比较清晰。
七、验证码业务中的异常使用
再看一个验证码登录的例子。
@Override
public String loginByCode(CodeLoginDTO loginDTO) {
String cacheCode = redisTemplate.opsForValue().get("login:code:" + loginDTO.getPhone());
if (!StringUtils.hasText(cacheCode)) {
throw new BusinessException(ResultCode.CAPTCHA_ERROR);
}
if (!cacheCode.equals(loginDTO.getCode())) {
throw new BusinessException(ResultCode.CAPTCHA_ERROR);
}
User user = userMapper.selectByPhone(loginDTO.getPhone());
if (user == null) {
throw new BusinessException(ResultCode.USER_NOT_FOUND);
}
return JwtUtil.generateToken(user.getId(), user.getUsername());
}
文字说明
验证码登录里面,最常见的异常就是验证码错误或过期。
这类异常非常适合封装成业务异常:
throw new BusinessException(ResultCode.CAPTCHA_ERROR);
这样 Controller 仍然可以保持很简单:
@PostMapping("/login/code")
public Result<String> loginByCode(@RequestBody CodeLoginDTO loginDTO) {
String token = userService.loginByCode(loginDTO);
return Result.success(token);
}
这就是统一异常处理带来的好处。
八、参数校验异常的配合使用
实际项目中,经常会配合 Validation 做参数校验。
1. DTO 参数类
package com.example.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
如果是 Spring Boot 2 项目,一般使用:
import javax.validation.constraints.NotBlank;
如果是 Spring Boot 3 项目,一般使用:
import jakarta.validation.constraints.NotBlank;
2. Controller 使用 @Valid
@PostMapping("/login")
public Result<String> login(@RequestBody @Valid LoginDTO loginDTO) {
String token = userService.login(loginDTO);
return Result.success(token);
}
文字说明
当请求参数不符合校验规则时,就会抛出 MethodArgumentNotValidException。
这个异常前面已经在 GlobalExceptionHandler 中统一处理了,所以前端会收到类似这样的结果:
{
"code": 400,
"message": "用户名不能为空",
"data": null
}
这比直接报一大堆异常信息要友好很多。
九、涉及知识点
1. @RestControllerAdvice
@RestControllerAdvice 是 Spring MVC 提供的全局异常处理注解。
它可以统一处理 Controller 层抛出的异常,并自动返回 JSON 数据。
2. @ExceptionHandler
@ExceptionHandler 用来指定某个方法处理哪一种异常。
比如:
@ExceptionHandler(BusinessException.class)
表示这个方法专门处理 BusinessException。
3. 业务异常和系统异常
业务异常一般是可预期的,比如:
系统异常一般是不可预期的,比如:
业务异常可以把具体原因返回给前端,系统异常一般只返回统一提示。
4. Controller 不应该堆业务判断
Controller 的职责应该尽量简单。
如果 Controller 里面写了大量业务判断和异常处理,后期接口一多,代码会很难维护。
十、常见问题
1. 为什么业务异常要继承 RuntimeException?
因为业务异常通常不希望每一层都强制 throws。
如果继承 Exception,很多方法都要显式声明异常,代码会变得比较啰嗦。
2. 为什么还要保留 Exception.class 兜底处理?
因为项目运行中可能出现一些没有预料到的异常。
如果没有兜底处理,前端可能会收到不统一的错误格式,甚至看到后端异常堆栈。
3. 系统异常能不能直接返回 e.getMessage()?
不建议。
因为系统异常信息可能暴露内部实现,比如 SQL、数据库字段、服务器路径等。更推荐返回统一文案,例如:
系统异常,请稍后再试
同时后端通过日志记录完整异常,方便排查。
4. 错误码一定要用枚举吗?
不是必须,但推荐。
如果项目比较小,直接写字符串也能用;但只要接口开始变多,错误码集中管理会更清晰。
十一、实际开发建议
1. Controller 层不要到处写 try-catch
一般接口不需要自己捕获业务异常。
业务异常交给全局异常处理器,Controller 只负责正常流程即可。
2. 业务失败优先抛 BusinessException
比如:
throw new BusinessException(ResultCode.USER_NOT_FOUND);
不要简单返回 null 或 false,否则调用方还要继续判断,代码会越来越乱。
3. 错误码要提前规划
可以按照模块划分错误码:
1000 – 用户模块
2000 – 验证码模块
3000 – Token 模块
4000 – 订单模块
5000 – 文件模块
这样前后端联调时更容易定位问题。
4. 日志和返回信息要区分
返回给前端的信息要简洁、安全。
日志里面可以记录更详细的异常信息,方便后端排查问题。
十二、总结
这一篇我们完成了 Spring Boot 项目里非常重要的一块内容:统一异常处理和业务异常封装。
它解决的不是某一个接口的问题,而是整个项目的接口规范问题。
通过这一篇,我们把异常处理从 Controller 中抽离出来,让业务失败统一抛 BusinessException,再通过 GlobalExceptionHandler 统一返回给前端。
这样做之后,项目结构会更加清晰:
这也是从“能写接口”到“写得像一个完整项目”的关键一步。




