【JavaEE25-后端部分】从“统一回执单”到“统一投诉处理”:Spring Boot 轻松搞定统一返回格式和统一异常处理

老铁们,上一期我们给图书系统配了一个"保安"------拦截器。他守在门口,检查每个人有没有门禁卡(Session),没卡就拦住,有卡才让进。这下不登录再也进不来了,安全多了。

但是,进了银行,你发现每个柜台的"回执单"五花八门:有的给你写纸条,有的给你开发票,有的啥也不给。出错了,你也不知道找谁投诉。我们的图书系统现在就处在这个阶段------保安到位了,但回执单乱七八糟,出了问题没人管

今天,我们就给系统加上统一返回格式统一异常处理,让图书系统真正变得像银行一样规范、可靠。最后,我们还要偷偷溜进 Spring 的"公司内部",看看它是怎么管理这些"员工"的。


一、统一返回格式:让每一张回执单都长一样

首先回顾我们之前的图书管理中的统一接口返回:

1.1 看看我们现在的"回执单"有多乱

打开我们的图书系统,你会发现每个接口返回的数据都不一样:

  • 登录接口 :返回 truefalse
  • 图书列表接口 :返回 {total: 10, records: [...]}
  • 添加图书接口 :成功返回空字符串 "",失败返回错误信息
  • 删除图书:返回空字符串或错误信息
  • 批量删除 :返回 truefalse

这就像去银行,有的窗口给你开一张发票,有的给你写个纸条,有的啥也不给。前端程序员要疯了:每个接口都得单独解析,万一记错就出错。统一返回格式,就是让所有接口都按一个标准来

1.2 设计我们的"回执单模板":Result 类

我们需要一个统一的"回执单模板",它应该包含三个信息:

  • 状态码:告诉前端这次请求是成功还是失败(比如 200 成功,-1 未登录,-2 失败)
  • 错误信息:如果失败,写清楚原因
  • 数据:成功时,把真正的业务数据放在这里

所以我们定义一个 Result 类:

java 复制代码
package com.zhongge.model;

import lombok.Data;

@Data
public class Result<T> {
    private ResultStatus status;   // 状态码(用枚举,更清晰)
    private String errorMessage;    // 错误信息
    private T data;                 // 真正的数据
}

为了让状态码更直观,我们再定义一个枚举:

java 复制代码
package com.zhongge.enums;

public enum ResultStatus {
    SUCCESS(200, "成功"),
    UNLOGIN(-1, "未登录"),
    FAIL(-2, "失败");

    private int code;
    private String msg;

    ResultStatus(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public int getCode() { return code; }
    public String getMsg() { return msg; }
}

为了方便创建结果,我们在 Result 类里加几个静态方法:

java 复制代码
public static <T> Result<T> success(T data) {
    Result<T> result = new Result<>();
    result.setStatus(ResultStatus.SUCCESS);
    result.setErrorMessage("");
    result.setData(data);
    return result;
}

public static <T> Result<T> fail(String errorMessage) {
    Result<T> result = new Result<>();
    result.setStatus(ResultStatus.FAIL);
    result.setErrorMessage(errorMessage);
    result.setData(null);
    return result;
}

public static <T> Result<T> unlogin() {
    Result<T> result = new Result<>();
    result.setStatus(ResultStatus.UNLOGIN);
    result.setErrorMessage("用户未登录");
    result.setData(null);
    return result;
}

这样,以后我们就可以用 Result.success(数据)Result.fail("错误信息") 来创建统一格式的返回结果。

那么现在:我们要返回统一格式,我们就不得不做如下操作

同理 其他的方法也是如此,那么有没有一种方法可以不用这么麻烦呢?

有的 此时就是需要我们的统一数据返回了。

1.3 给每个接口手动包装,太累了

如果我们每个 Controller 方法里都手动包装,比如:

java 复制代码
@RequestMapping("/getListByPage")
public Result<PageResult<BookInfo>> getListByPage(...) {
    PageResult<BookInfo> pageResult = bookService.getListByPage(...);
    return Result.success(pageResult);
}

这虽然统一了,但每个接口都要写这么一句,还是麻烦。有没有办法让 Spring 自动帮我们包装?当然有!

1.4 派一个"前台接待员"自动包装

我们可以派一个前台接待员 ,他站在 Controller 和返回结果之间。不管 Controller 返回什么,他都帮你包装成统一的 Result 格式。这个接待员就是 Spring 提供的 ResponseBodyAdvice 接口,配合 @ControllerAdvice 注解。ResponseBodyAdvice翻译为响应体建议器

写一个"前台接待员"类 (放在 com.zhongge.advice 包下):








此时启动服务器看效果:


java 复制代码
package com.zhongge.advice;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.zhongge.model.Result;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@Slf4j
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        // 返回 true 表示对所有接口都生效
        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                  MediaType selectedContentType, Class selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        // 如果已经是 Result 类型,说明已经包装过,直接返回
        if (body instanceof Result) {
            return body;
        }
        // 如果返回的是字符串(比如添加图书成功时返回空字符串),需要特殊处理
        if (body instanceof String) {
            return objectMapper.writeValueAsString(Result.success(body));
        }
        // 其他类型,直接包装成 Result.success
        return Result.success(body);
    }
}

接下里写几个测试用例:

java 复制代码
package com.zhongge.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @ClassName TestController
 * @Description TODO 测试统一返回格式
 * @Author 笨忠
 * @Date 2026-04-02 16:12
 * @Version 1.0
 */
@RequestMapping("/test")
@RestController
public class TestController {
    
    @RequestMapping("/t1")
    public Integer t1() {
        return 1;
    }
    @RequestMapping("/t2")
    public Boolean t2() {
        return true;
    }
    @RequestMapping("/t3")
    public String t4() {
        return "t3";
    }
}

测试t1:


测试t2:


测试t3:返回字符串会报错


那么接下来,我们返回false然后再测试t1,t2,t3


测试t1:


测试t2:


测试t3:返回字符串会报错


原因:

我明明用了 @RestController!为什么返回 String 还是报错???

一)、先给结论(最重要)

即使加了 @RestController,Controller 返回 String + 统一返回包装(ResponseBodyAdvice)依然会报错!

这是 SpringMVC 祖传经典坑


二)、为什么 @RestController 没用?原因讲透

1. @RestController 到底干了啥?

它 = @Controller + @ResponseBody

作用只有一个:
告诉 Spring:这个类所有方法,都返回 JSON/文本,不是页面!

所以正常情况下:

java 复制代码
@RestController
public String test() {
    return "hello";
}

直接返回字符串 hello,完全没问题!


2. 但是!你加了统一返回包装(ResponseBodyAdvice)

事情就变了!

执行顺序是这样的:
  1. 你的方法返回 String "t3"

  2. Spring 知道 @RestController → 不按视图走

  3. 进入你的 ResponseBodyAdvice

  4. 你把 String 包装成了 Result 对象

    java 复制代码
    return Result.success(body);
  5. 重点来了!
    Spring 此时要把 Result 对象 转成 JSON
    但是!
    Spring 内置的 String 消息转换器,不认识 Result 对象!

最终报错:
复制代码
Could not write JSON: Class ...Result not be cast to String

或者直接类型转换异常!


三)、用最通俗的比喻

你去快递站寄东西:

  • 你寄的是 字符串(信)
  • @RestController 就是快递单:写了「文本信件」
  • 统一返回包装 = 你非要把信装进一个 盒子(Result)
  • 快递员(String消息转换器)只负责送信,不负责收盒子
  • 于是快递员直接报错:我不处理盒子!

四)、真正的根本原因

Spring 有多个消息转换器:

  1. StringHttpMessageConverter (优先级最高!)
  2. MappingJackson2HttpMessageConverter (转对象)

当你的返回值是 String:

  • 直接走到 String转换器
  • 但你在 beforeBodyWrite 里把它变成了 Result对象
  • String转换器 只能处理 String,不能处理对象
  • 直接类型转换失败 → 报错!

五)、最简单的解决办法(只加 4 行代码)

在你的 beforeBodyWrite 里加一段:
如果是 String,我手动转成 JSON 字符串再给你!

代码最终版
java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {

    // 用来转 JSON
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {

        Result result = Result.success(body);

        // 👇👇👇 就是加这一段!解决所有问题!
        if (body instanceof String) {
            try {
                return objectMapper.writeValueAsString(result);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        return result;
    }
}

推荐使用的解决办法

或者使用下述这种简单的方法

java 复制代码
package com.zhongge.advice;

import com.zhongge.model.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import tools.jackson.databind.ObjectMapper;

/**
 * @ClassName ResponseAdvice
 * @Description TODO 统一数据返回格式
 * @Author 笨忠
 * @Date 2026-04-02 11:37
 * @Version 1.0
 */
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {

    @Autowired
    private ObjectMapper objectMapper;
    
    @Override
    public boolean supports(MethodParameter returnType,
                            Class converterType) {

        //返回false表示不生效 不处理
        //返回true表示处理
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,//响应的正文
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        // 如果已经是 Result 类型,说明已经包装过,直接返回
        if (body instanceof Result) {
            return body;
        }
        // 如果返回的是字符串(比如添加图书成功时返回空字符串),需要特殊处理
        if (body instanceof String) {
            return objectMapper.writeValueAsString(Result.success(body));
        }
        // 其他类型,直接包装成 Result.success
        return Result.success(body);
    }
}

知识点:异常的注解


解释转为json逻辑:


六)、现在再访问

复制代码
/test/t3   返回 String

完美输出 JSON,不报错!

json 复制代码
{
  "code": 200,
  "msg": "成功",
  "data": "t3"
}

七)、最后总结

  1. @RestController 只能保证:不把 String 当页面
  2. 但不能解决:String 转换器不认识 Result 对象的问题
  3. 只要用了统一返回包装,返回 String 100% 会报错
  4. 解决方案:判断 String,手动转 JSON 字符串

简单解释一下这个接待员的工作

  • @ControllerAdvice:告诉 Spring 这是一个"全局控制器通知"。就像银行门口贴了告示:"所有窗口业务,都要经过前台审核"。这个注解会被 Spring 自动扫描,不需要手动注册。
  • supports 方法:这是接待员的"接单标准"。它问:"这个接口的返回值,我要不要处理?"我们返回 true,表示所有接口都处理。你也可以根据 returnType 判断,比如只处理带某个注解的方法。
  • beforeBodyWrite 方法:这是接待员的"实际工作"。它在 Controller 返回结果写入响应之前 执行,接收 Controller 返回的 body,然后可以修改它。
    • 第一层判断:如果 body 已经是 Result 类型,说明之前可能已经包装过了,那就直接返回,避免重复包装。
    • 第二层判断:如果 body 是字符串(比如添加图书成功时返回空字符串 ""),需要特殊处理 。为什么?因为 Spring 默认有一个 StringHttpMessageConverter,它只认识字符串。如果我们返回一个 Result 对象,它会尝试用字符串转换器处理,结果就会报错。所以我们要手动把 Result 对象转成 JSON 字符串再返回,这样就能顺利通过了。
    • 第三层:其他类型(比如 PageResulttruefalse),直接包装成 Result.success(body) 返回。

1.5 测试一下

启动项目,访问 http://localhost:8080/book/queryBookById?bookId=1,之前返回的是图书对象:

json 复制代码
{
  "id": 1,
  "bookName": "解忧杂货店",
  "author": "东野圭吾",
  "count": 22,
  "price": 39.80,
  "publish": "南海出版公司",
  "status": 1,
  "createTime": "2026-03-25T15:08:44.000Z",
  "updateTime": "2026-03-25T15:08:44.000Z"
}

现在自动变成了:

json 复制代码
{
  "status": {
    "code": 200,
    "msg": "成功"
  },
  "errorMessage": "",
  "data": {
    "id": 1,
    "bookName": "解忧杂货店",
    "author": "东野圭吾",
    "count": 22,
    "price": 39.80,
    "publish": "南海出版公司",
    "status": 1,
    "createTime": "2026-03-25T15:08:44.000Z",
    "updateTime": "2026-03-25T15:08:44.000Z"
  }
}

访问添加图书接口 http://localhost:8080/book/addBook(带上正确的参数),之前返回空字符串,现在返回:

json 复制代码
{
  "status": {
    "code": 200,
    "msg": "成功"
  },
  "errorMessage": "",
  "data": ""
}

完美!所有接口的返回格式都统一了。前端程序员再也不用为每个接口单独写解析代码了。


1.6 统一返回格式流程图

1)请求进来(固定顺序)

前端


过滤器(请求方向)

拦截器 preHandle

Controller

Service

Mapper

DB


2)响应

DB → Mapper → Service → Controller

拦截器 postHandle(正常才执行


统一返回格式包装


过滤器(响应方向)

前端



1.7 处理响应类型

你看这张图,还记得我们返回的是一个JSON对象吗?

但是呢,他这里我们进行一个数据抓包,他返回的格式是一个文本:

因此,为了前端能够解析它,我们得在后台响应的时候给它设置一个类型为json,使得让它变为一个json对象。

接下来我们把所有接口返回字符串的情况都给他设置返回类型为json。

1、添加图书

2、查询图书

然后你会发现我们返回的类型是string的话,我们还得搞这么多麻烦的事情,所以呢我们在后续的一些项目中啊,一般不会直接返回String类型。我们可以直接让他返回给result进行封装,然后到时候我们在那个统一返回格式那里进行一个判断的话,就直接给他返回了,你不返回也可以,我们都会给他包装的。

此时我们进行一个测试,它就正常了:


二、统一异常处理:给系统配上"客服中心"

你看,我们有些方法确实是有异常处理的

但是有些方法我们没有做处理

举个例子,比如我们这个获取突出列表的方法没有异常处理,但是如果我们将后台的那个数据表表名给修改掉:

那此时肯定是抛异常的,而且你抛异常,由于你没有处理,他返回给前端的仍然是一个成功:

你看,虽然说我们的查询你没有去处理异常,你觉得查询的话,一般不会有什么失误的问题,但是呢,我们如果数据库的sql写错了一个字母,那他就会报错,而报错它返回给前端的话,居然是成功,这肯定是不可取的。


首先解释这样的一个原因,你看下图:

他虽然抛出异常,但他仍然会走到我们的统一返回格式这里边进行一个封装,他把这个异常也给封装进去:

那这里其实是跟我们这个流程有关,我们后续会画一个对应的图(统一异常的流程图,他呢是先搞异常,然后再进入统一返回格式的)。


我们在开发的时候难免会不可能对所有的方法你都能够想到做一个怎样的异常处理,所以呢此时我们就需要一个统一异常处理。

那我们怎么处理呢?这里有一个方法:比如你看这个body,如果这个body是一个异常,那你就不要封装成success了,你封装成一个其他的比如失败fail:

在我们的统一返回格式中

在这里边我们来学我们spring给我们提供的一种方法:它呢是借助controller advice注解和exception handler注解,还额外加一个responsebody注解,它呢用来告诉你返回的是数据,而不是页面,否则会出错

2.1 没有客服中心的后果

现在,如果我们的代码出错了,比如数据库连接失败、除零错误、空指针异常......Spring 会直接返回一个错误页面或一堆看不懂的堆栈信息。前端收到这种乱七八糟的响应,根本不知道怎么处理。

这就像你去银行办业务,突然柜员电脑蓝屏了,他直接甩给你一个错误代码,说"你自己看着办吧",你懵不懵?我们需要一个"客服中心",不管发生什么异常,都统一处理,返回一个格式化的错误信息给前端。

2.2 派一个"客服经理"统一处理

统一异常第一种写法

我们这里定一个类:

java 复制代码
package com.zhongge.advice;

/**
 * @ClassName ErrorAdvice
 * @Description TODO 统一异常处理
 * @Author 笨忠
 * @Date 2026-04-02 18:26
 * @Version 1.0
 */
public class ErrorAdvice {
}

此时我们写一些异常测试代码

重启我们的服务器,看结果:

然后观察后端的日志,你会发现,后端的日志里面没有异常信息,所以我们也一般不会把详细的异常信息告诉给后端

然后我们若想看详细的日志的话,我们就使用日志框架来给我们打印出对应的日志:


如果你不使用response body的话,那你返回的就不是一个数据,而是我们的页面,那此时的话,我们看如下的效果:

不过此时必须引入模板引擎( thymeleaf ),而且你的页面要放在resources/templates/error.html

那么测试结果如下:

只不过我们后端一般返回的都是数据,所以呢我们不用操这些心


那么我们接下来后端可以针对不同的异常去进行处理:


测试t2:

测试t3:

测试t1:

从上面我们也发现到了,他走的一个流程是就近原则,谁离他那个异常比较近,他就走哪个方法,比如我们的t2,t2是一个数组越界异常,所以呢t2被捕获的时候,直接走的是这个数组越界,他不会走们的那个exception,因为exception是离它远一点,而数组越界异常离他近一点,而比如我们的t1,他的话是一个算术异常,但是我们没有定义算术异常,离他近的只有那个第1个一个兜底的异常exception。


统一异常第二种写法

接下来我们看第2种写法如何写?也就是说我们异常类型是可以声明在注解 Exception handler属性中的:

Spring 提供了 @ExceptionHandler 注解,配合 @ControllerAdvice,可以定义全局异常处理器。

写一个"客服经理"类 (放在 com.zhongge.advice 包下):

java 复制代码
package com.zhongge.advice;

import com.zhongge.model.Result;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@ControllerAdvice
@ResponseBody
public class ErrorAdvice {

    // 处理所有 Exception 异常(兜底的)
    @ExceptionHandler(Exception.class)
    public Object handleException(Exception e) {
        // 实际开发中应该用 log.error 记录详细堆栈
        return Result.fail("系统异常:" + e.getMessage());
    }

    // 处理空指针异常(更具体)
    @ExceptionHandler(NullPointerException.class)
    public Object handleNullPointerException(NullPointerException e) {
        return Result.fail("空指针异常:" + e.getMessage());
    }

    // 处理算术异常(除零)
    @ExceptionHandler(ArithmeticException.class)
    public Object handleArithmeticException(ArithmeticException e) {
        return Result.fail("算术异常:" + e.getMessage());
    }
}

我们来解释这个"客服经理"的工作流程

  • @ControllerAdvice:同样是全局控制器通知,告诉 Spring 这个类要处理全局事务。
  • @ResponseBody:表示返回的结果直接写入响应体,而不是跳转到错误页面。因为我们前后端分离,所有响应都是 JSON,所以必须加这个注解。
  • @ExceptionHandler:指定要处理的异常类型。可以写多个方法,每个方法处理一种异常。

异常匹配规则 :Spring 会按照最匹配 的原则选择异常处理方法。比如抛出 NullPointerException,会优先匹配到 handleNullPointerException,而不是更宽泛的 handleException。如果都没有匹配,就走 Exception.class 这个兜底的。

2.3 测试一下

在 Controller 里故意制造异常,比如:

java 复制代码
@RequestMapping("/test")
public String test() {
    int a = 10 / 0;  // 抛出 ArithmeticException
    return "test";
}

访问这个接口,前端会收到:

json 复制代码
{
  "status": {
    "code": -2,
    "msg": "失败"
  },
  "errorMessage": "算术异常:/ by zero",
  "data": null
}

再制造一个空指针异常:

java 复制代码
@RequestMapping("/test2")
public String test2() {
    String str = null;
    str.length();  // 抛出 NullPointerException
    return "test2";
}

返回:

json 复制代码
{
  "status": {
    "code": -2,
    "msg": "失败"
  },
  "errorMessage": "空指针异常:null",
  "data": null
}

完美!无论哪里出错,前端都能收到统一格式的错误信息,可以友好地提示用户"系统出错了,请稍后重试",而不是看到一堆技术细节。


2.4 统一异常流程图

注意:

  • 过滤器 Filter:请求进会走,响应出也会走
  • 拦截器 Interceptor:只有正常返回才走 postHandle,异常不走
  • 统一异常处理:抛异常才进,正常不进
  • 统一返回格式:不管正常还是异常,最后都进

1)请求

前端


过滤器(请求方向)

拦截器 preHandle

Controller

Service

Mapper

DB



响应情况 A:正常、没有异常

DB → Mapper → Service → Controller

拦截器 postHandle(正常才执行


统一返回格式包装


过滤器(响应方向)

前端



相应情况 B:任何一层抛异常(Controller/Service/Mapper/DB)

抛出异常 → 后面代码全部终止


统一异常处理 (生成错误结果)


统一返回格式包装


过滤器(响应方向)

前端



三、内部探秘:@ControllerAdvice 是怎么工作的?

你可能会好奇:我们只是加了个注解,Spring 是怎么找到这些类并在适当时候调用它们的?这就像公司里的人力资源部,怎么招聘和管理这些"员工"?

3.1 把 @ControllerAdvice 想象成"招聘启事"

当你写了一个类,并加上 @ControllerAdvice 注解,就好比在招聘网站上发了个招聘启事,上面写着:

  • 职位:前台接待员 / 客服经理
  • 能力:能包装返回结果 / 能处理异常
  • 工作范围:整个公司

Spring 启动时,会派一个"人事部"去扫描所有类,找到所有带 @ControllerAdvice 的类,然后把他们登记到人才库里。这个人才库分为两个部门:

  • 响应包装部 :存放实现了 ResponseBodyAdvice 的类(前台接待员)
  • 异常处理部 :存放带有 @ExceptionHandler 方法的类(客服经理)

3.2 "人事部"在哪儿?看源码

Spring 的"人事部"藏在 RequestMappingHandlerAdapterExceptionHandlerExceptionResolver 这两个类里。

响应包装部的招聘流程 (在 RequestMappingHandlerAdapterinitControllerAdviceCache 方法中):

java 复制代码
private void initControllerAdviceCache() {
    // 1. 找所有带 @ControllerAdvice 的类
    List<ControllerAdviceBean> adviceBeans = 
        ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
    
    List<Object> requestResponseBodyAdviceBeans = new ArrayList<>();
    
    for (ControllerAdviceBean adviceBean : adviceBeans) {
        Class<?> beanType = adviceBean.getBeanType();
        
        // 2. 检查这个类是不是实现了 ResponseBodyAdvice 接口
        if (ResponseBodyAdvice.class.isAssignableFrom(beanType)) {
            requestResponseBodyAdviceBeans.add(adviceBean);
        }
    }
    
    // 3. 把找到的"前台接待员"存起来
    this.requestResponseBodyAdvice.addAll(0, requestResponseBodyAdviceBeans);
}

翻译成人话:

  1. 招聘 :扫描所有类,找出带 @ControllerAdvice 的。
  2. 筛选 :看看这些类里哪些实现了 ResponseBodyAdvice 接口(会包装返回结果的)。
  3. 入职:把这些"前台接待员"存到一个列表里,等着以后调用。

异常处理部的招聘流程 (在 ExceptionHandlerExceptionResolverinitExceptionHandlerAdviceCache 方法中):

java 复制代码
private void initExceptionHandlerAdviceCache() {
    // 1. 找所有带 @ControllerAdvice 的类
    List<ControllerAdviceBean> adviceBeans = 
        ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
    
    for (ControllerAdviceBean adviceBean : adviceBeans) {
        Class<?> beanType = adviceBean.getBeanType();
        
        // 2. 分析这个类里所有带 @ExceptionHandler 的方法
        ExceptionHandlerMethodResolver resolver = 
            new ExceptionHandlerMethodResolver(beanType);
        
        if (resolver.hasExceptionMappings()) {
            // 3. 把"客服经理"和他的"处理能力"存起来
            this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
        }
    }
}

翻译成人话:

  1. 招聘 :还是扫描所有带 @ControllerAdvice 的类。
  2. 技能分析 :看看这个类里有哪些方法加了 @ExceptionHandler,能处理哪些异常。
  3. 入职:把这些"客服经理"和他的技能清单存到一个字典里,方便以后快速找到谁负责处理哪种异常。

3.3 工作中,怎么叫他们?

正常请求时 :当 Controller 返回结果,Spring 会调用 writeWithMessageConverters 方法,里面有这么一行:

java 复制代码
body = getAdvice().beforeBodyWrite(body, ...);

getAdvice() 会从之前存储的 requestResponseBodyAdvice 列表中,找到所有"前台接待员",依次执行他们的 beforeBodyWrite 方法。这就是为什么我们写的 beforeBodyWrite 会被自动调用。

发生异常时 :当 Controller 抛出异常,Spring 会来到 ExceptionHandlerExceptionResolver,它去 exceptionHandlerAdviceCache 这个字典里找,看哪个"客服经理"能处理当前异常。根据异常类型匹配,找到后调用对应的方法。

3.4 为什么字符串类型会报错?

我们在统一返回格式时遇到一个坑:Controller 返回 String 类型时,我们的 beforeBodyWrite 返回的是 Result 对象,但 Spring 默认的 StringHttpMessageConverter 只认识字符串,不认识 Result 对象,结果报错。

解决办法:在 beforeBodyWrite 里加一个判断,如果返回的是字符串,手动把 Result 对象转成 JSON 字符串,这样 StringHttpMessageConverter 就能顺利处理了。就像"前台接待员"把包裹重新包装后,还贴上了标签,告诉送货员怎么送。


四、前端配合调整:因为后端变了,前端也要跟着变

在修改前端代码之前,我们需要先搞清楚一个现象:为什么后端返回的 status 字段是字符串 "SUCCESS",而不是数字 200


现象解释:为什么返回的是字符串而不是数字?

我们后端的 Result 类中,status 字段类型是枚举 ResultStatus

java 复制代码
@Data
public class Result<T> {
    private ResultStatus status;  // 枚举类型
    // ...
}

ResultStatus 枚举定义如下:

java 复制代码
public enum ResultStatus {
    SUCCESS(200),
    UNLOGIN(-1),
    FAIL(-2);

    private Integer code;
    // ...
}

当 Spring Boot 使用 Jackson 将 Java 对象序列化为 JSON 时,对于枚举类型,默认会调用枚举的 name() 方法 ,即返回枚举常量的名称(大写字符串)。因此,ResultStatus.SUCCESS 会被序列化为 "SUCCESS",而不是数字 200

如果想返回数字,需要给枚举的 getCode() 方法加上 @JsonValue 注解,告诉 Jackson 用这个方法的返回值作为序列化结果。

但当前代码中没有加这个注解,所以前端收到的 status 就是字符串 "SUCCESS""UNLOGIN""FAIL"

因此,前端判断登录状态时,不能使用 result.status.code === 200,而应该直接比较字符串:result.status === "SUCCESS"

基于这个现象,我们对所有前端页面进行了适配。下面是修改后的完整页面代码,可以直接替换使用。


1. 登录页面 (login.html)

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录</title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/login.css">
    <script type="text/javascript" src="js/jquery.min.js"></script>
</head>

<body>
    <div class="container-login">
        <div class="container-pic">
            <img src="pic/computer.png" width="350px">
        </div>
        <div class="login-dialog">
            <h3>登陆</h3>
            <div class="row">
                <span>用户名</span>
                <input type="text" name="userName" id="userName" class="form-control">
            </div>
            <div class="row">
                <span>密码</span>
                <input type="password" name="password" id="password" class="form-control">
            </div>
            <div class="row">
                <button type="button" class="btn btn-info btn-lg" onclick="login()">登录</button>
            </div>
        </div>
    </div>
    <script>
        function login() {
            $.ajax({
                method: "post",
                url: "/user/login",
                data: {
                    name: $("#userName").val(),
                    password: $("#password").val()
                },
                success: function(result) {
                    // 后端返回统一 Result 格式,status 为字符串
                    if (result.status === "SUCCESS" && result.data === true) {
                        location.href = "book_list.html";
                    } else {
                        alert(result.errorMessage || "账号和密码错误");
                    }
                },
                error: function(error) {
                    if (error.status === 401) {
                        alert("用户未登录,请先登录!");
                        location.href = "login.html";
                    } else {
                        alert("登录失败,请稍后重试");
                    }
                }
            });
        }
    </script>
</body>

</html>

2. 图书列表页面 (book_list.html)

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>图书列表展示</title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/list.css">
    <script type="text/javascript" src="js/jquery.min.js"></script>
    <script type="text/javascript" src="js/bootstrap.min.js"></script>
    <script src="js/jq-paginator.js"></script>
</head>

<body>
    <div class="bookContainer">
        <h2>图书列表展示</h2>
        <div class="navbar-justify-between">
            <div>
                <button class="btn btn-outline-info" type="button" onclick="location.href='book_add.html'">添加图书</button>
                <button class="btn btn-outline-info" type="button" onclick="batchDelete()">批量删除</button>
            </div>
        </div>

        <table class="table">
            <thead>
                <tr>
                    <th>选择</th>
                    <td class="width100">图书ID</td>
                    <th>书名</th>
                    <th>作者</th>
                    <th>数量</th>
                    <th>定价</th>
                    <th>出版社</th>
                    <th>状态</th>
                    <td class="width200">操作</td>
                </tr>
            </thead>
            <tbody></tbody>
        </table>

        <div class="demo">
            <ul id="pageContainer" class="pagination justify-content-center"></ul>
        </div>

        <script>
            let currentPage = 1;
            const pageSize = 5;
            let totalCount = 0;
            let isPaginationInit = false;

            function loadPageData(page) {
                $.ajax({
                    type: "get",
                    url: "/book/getListByPage",
                    data: { currentPage: page, pageSize: pageSize },
                    success: function (result) {
                        // 统一判断 Result 状态,status 为字符串
                        if (result.status === "SUCCESS") {
                            var data = result.data;
                            var books = data.records;
                            var finalHtml = "";
                            for (var book of books) {
                                finalHtml += '<tr>';
                                finalHtml += '<td><input type="checkbox" name="selectBook" value="' + book.id + '" class="book-select"></td>';
                                finalHtml += '<td>' + book.id + '</td>';
                                finalHtml += '<td>' + book.bookName + '</td>';
                                finalHtml += '<td>' + book.author + '</td>';
                                finalHtml += '<td>' + book.count + '</td>';
                                finalHtml += '<td>' + book.price + '</td>';
                                finalHtml += '<td>' + book.publish + '</td>';
                                finalHtml += '<td>' + book.statusCN + '</td>';
                                finalHtml += '<td><div class="op">';
                                finalHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';
                                finalHtml += '<a href="javascript:void(0)" onclick="deleteBook(' + book.id + ')">删除</a>';
                                finalHtml += '</div></td>';
                                finalHtml += '</tr>';
                            }
                            $("tbody").html(finalHtml);
                            totalCount = data.total;
                            if (!isPaginationInit) {
                                initPagination(totalCount, page);
                                isPaginationInit = true;
                            }
                        } else {
                            alert(result.errorMessage || "获取数据失败");
                            if (result.status === "UNLOGIN") {
                                alert("用户未登录,请先登录!");
                                location.href = "login.html";
                            }
                        }
                    },
                    error: function (error) {
                        if (error.status === 401) {
                            alert("用户未登录,请先登录!");
                            location.href = "login.html";
                        } else {
                            alert("加载图书数据失败,请稍后重试");
                        }
                    }
                });
            }

            function initPagination(total, current) {
                $("#pageContainer").jqPaginator({
                    totalCounts: total,
                    pageSize: pageSize,
                    visiblePages: 5,
                    currentPage: current,
                    first: '<li class="page-item"><a class="page-link">首页</a></li>',
                    prev: '<li class="page-item"><a class="page-link">上一页</a></li>',
                    next: '<li class="page-item"><a class="page-link">下一页</a></li>',
                    last: '<li class="page-item"><a class="page-link">最后一页</a></li>',
                    page: '<li class="page-item"><a class="page-link">{{page}}</a></li>',
                    onPageChange: function (page) {
                        currentPage = page;
                        loadPageData(currentPage);
                    }
                });
            }

            $(document).ready(function () {
                loadPageData(1);
            });

            function deleteBook(id) {
                var isDelete = confirm("确认删除?");
                if (isDelete) {
                    $.ajax({
                        type: "post",
                        url: "/book/updateBook",
                        data: { id: id, status: 0 },
                        success: function (result) {
                            if (result.status === "SUCCESS" && result.data === "") {
                                alert("删除成功");
                                loadPageData(currentPage);
                            } else {
                                alert(result.errorMessage || "删除失败");
                            }
                        },
                        error: function (error) {
                            if (error.status === 401) {
                                alert("用户未登录,请先登录!");
                                location.href = "login.html";
                            } else {
                                alert("删除失败,请稍后重试");
                            }
                        }
                    });
                }
            }

            function batchDelete() {
                var ids = [];
                $("input:checkbox[name='selectBook']:checked").each(function () {
                    ids.push($(this).val());
                });
                if (ids.length === 0) {
                    alert("请至少选择一本图书");
                    return;
                }
                if (!confirm("确认批量删除选中的 " + ids.length + " 本图书吗?")) {
                    return;
                }
                $.ajax({
                    type: "post",
                    url: "/book/batchDeleteBook",
                    data: { ids: ids },
                    traditional: true,
                    success: function (result) {
                        if (result.status === "SUCCESS" && result.data === true) {
                            alert("批量删除成功");
                            loadPageData(currentPage);
                        } else {
                            alert(result.errorMessage || "批量删除失败");
                        }
                    },
                    error: function (error) {
                        if (error.status === 401) {
                            alert("用户未登录,请先登录!");
                            location.href = "login.html";
                        } else {
                            alert("批量删除失败,请稍后重试");
                        }
                    }
                });
            }
        </script>
    </div>
</body>

</html>

3. 添加图书页面 (book_add.html)

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>添加图书</title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/add.css">
</head>

<body>
    <div class="container">
        <div class="form-inline">
            <h2 style="text-align: left; margin-left: 10px;">
                <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="#17a2b8">
                    <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
                </svg>
                <span>添加图书</span>
            </h2>
        </div>

        <form id="addBook">
            <div class="form-group">
                <label for="bookName">图书名称:</label>
                <input type="text" class="form-control" placeholder="请输入图书名称" id="bookName" name="bookName">
            </div>
            <div class="form-group">
                <label for="bookAuthor">图书作者</label>
                <input type="text" class="form-control" placeholder="请输入图书作者" id="bookAuthor" name="author" />
            </div>
            <div class="form-group">
                <label for="bookStock">图书库存</label>
                <input type="text" class="form-control" placeholder="请输入图书库存" id="bookStock" name="count"/>
            </div>
            <div class="form-group">
                <label for="bookPrice">图书定价:</label>
                <input type="number" class="form-control" placeholder="请输入价格" id="bookPrice" name="price">
            </div>
            <div class="form-group">
                <label for="bookPublisher">出版社</label>
                <input type="text" id="bookPublisher" class="form-control" placeholder="请输入图书出版社" name="publish" />
            </div>
            <div class="form-group">
                <label for="bookStatus">图书状态</label>
                <select class="custom-select" id="bookStatus" name="status">
                    <option value="1" selected>可借阅</option>
                    <option value="2">不可借阅</option>
                </select>
            </div>
            <div class="form-group" style="text-align: right">
                <button type="button" class="btn btn-info btn-lg" onclick="add()">确定</button>
                <button type="button" class="btn btn-secondary btn-lg" onclick="javascript:history.back()">返回</button>
            </div>
        </form>
    </div>
    <script type="text/javascript" src="js/jquery.min.js"></script>
    <script>
        function add() {
            $.ajax({
                method: "post",
                url: "/book/addBook",
                data: $("#addBook").serialize(),
                success: function(result) {
                    if (result.status === "SUCCESS" && result.data === "") {
                        alert("添加成功");
                        location.href = "book_list.html";
                    } else {
                        alert(result.errorMessage || "添加失败");
                    }
                },
                error: function(error) {
                    if (error.status === 401) {
                        alert("用户未登录,请先登录!");
                        location.href = "login.html";
                    } else {
                        alert("添加失败,请稍后重试");
                    }
                }
            });
        }
    </script>
</body>

</html>

4. 修改图书页面 (book_update.html)

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>修改图书</title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/add.css">
</head>

<body>
    <div class="container">
        <div class="form-inline">
            <h2 style="text-align: left; margin-left: 10px;">
                <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="#17a2b8">
                    <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
                </svg>
                <span>修改图书</span>
            </h2>
        </div>

        <form id="updateBook">
            <input type="hidden" class="form-control" id="bookId" name="id">
            <div class="form-group">
                <label for="bookName">图书名称:</label>
                <input type="text" class="form-control" id="bookName" name="bookName">
            </div>
            <div class="form-group">
                <label for="bookAuthor">图书作者</label>
                <input type="text" class="form-control" id="bookAuthor" name="author"/>
            </div>
            <div class="form-group">
                <label for="bookStock">图书库存</label>
                <input type="text" class="form-control" id="bookStock" name="count"/>
            </div>
            <div class="form-group">
                <label for="bookPrice">图书定价:</label>
                <input type="number" step="0.01" class="form-control" id="bookPrice" name="price">
            </div>
            <div class="form-group">
                <label for="bookPublisher">出版社</label>
                <input type="text" id="bookPublisher" class="form-control" name="publish"/>
            </div>
            <div class="form-group">
                <label for="bookStatus">图书状态</label>
                <select class="custom-select" id="bookStatus" name="status">
                    <option value="1">可借阅</option>
                    <option value="2">不可借阅</option>
                </select>
            </div>
            <div class="form-group" style="text-align: right">
                <button type="button" class="btn btn-info btn-lg" onclick="update()">确定</button>
                <button type="button" class="btn btn-secondary btn-lg" onclick="javascript:history.back()">返回</button>
            </div>
        </form>
    </div>

    <script type="text/javascript" src="js/jquery.min.js"></script>
    <script>
        function getUrlParam(name) {
            var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            var r = window.location.search.substr(1).match(reg);
            if (r != null) return decodeURIComponent(r[2]);
            return null;
        }

        function getBookInfo() {
            var bookId = getUrlParam("bookId");
            if (!bookId) {
                alert("未获取到图书ID,请从列表页进入!");
                return;
            }
            $.ajax({
                type: "get",
                url: "/book/queryBookById",
                data: { bookId: bookId },
                success: function (result) {
                    // 后端返回 Result 包装,真正的图书数据在 data 里
                    if (result.status === "SUCCESS" && result.data && result.data.id) {
                        var book = result.data;
                        $("#bookId").val(book.id);
                        $("#bookName").val(book.bookName);
                        $("#bookAuthor").val(book.author);
                        $("#bookStock").val(book.count);
                        $("#bookPrice").val(book.price);
                        $("#bookPublisher").val(book.publish);
                        $("#bookStatus").val(book.status);
                    } else {
                        console.error("获取图书信息失败", result);
                        alert(result.errorMessage || "获取图书信息失败,请重试");
                    }
                },
                error: function (error) {
                    if (error.status === 401) {
                        alert("用户未登录,请先登录!");
                        location.href = "login.html";
                    } else {
                        console.error("查询失败", error);
                        alert("网络错误,请稍后重试");
                    }
                }
            });
        }

        function update() {
            if ($("#bookName").val().trim() === "") {
                alert("图书名称不能为空");
                return;
            }
            $.ajax({
                type: "post",
                url: "/book/updateBook",
                data: $("#updateBook").serialize(),
                success: function (result) {
                    if (result.status === "SUCCESS" && result.data === "") {
                        alert("修改成功");
                        location.href = "book_list.html?currentPage=1";
                    } else {
                        alert(result.errorMessage || "修改失败");
                    }
                },
                error: function (error) {
                    if (error.status === 401) {
                        alert("用户未登录,请先登录!");
                        location.href = "login.html";
                    } else {
                        alert("修改失败,请稍后重试");
                    }
                }
            });
        }

        $(document).ready(function () {
            getBookInfo();
        });
    </script>
</body>

</html>

以上四个页面已经完整适配了后端的统一返回格式(status 为字符串),并正确处理了拦截器返回的 401 状态码(未登录时提示并跳转)。

简单举个例子,我们未登录直接访问添加图书页面:

此时我们点击确定之后,他这个ajax请求就会被拦截返回401要求我们跳到登录页面去


五、总结:三步走,图书系统终于完整了

回顾一下,我们通过三步让图书系统脱胎换骨:

  1. 拦截器(保安):统一登录校验,不用再在每个接口里写重复代码。
  2. 统一返回格式(前台接待员) :所有接口返回统一格式的 Result,前端再也不用为不同接口写不同解析逻辑。
  3. 统一异常处理(客服经理):所有异常都返回统一格式的错误信息,前端可以统一提示用户。

加上源码分析,我们了解了 Spring 内部如何通过 @ControllerAdvice 管理这些"员工"。

现在,无论用户是否登录、无论业务成功还是失败、无论是否出现异常,前端都能收到统一格式的响应。我们的图书系统,终于从"手工作坊"升级成了"规范工厂"。

最终完善的项目源码地址Gitee 图书管理系统


老铁们,如果你觉得这篇文章对你有帮助,别忘了👍点赞⭐ 收藏👀 关注,🦀🦀各位老铁的支持~~

相关推荐
leo_messi942 小时前
2026版商城项目(二)-- 压力测试&缓存
java·缓存·压力测试·springcloud
ok_hahaha2 小时前
java从头开始-黑马点评-附近商户
java
丶小鱼丶2 小时前
数据结构和算法之【阻塞队列】下篇
java·数据结构
啥咕啦呛2 小时前
跟着AI学java第4天:面向对象编程巩固
java·开发语言·人工智能
lThE ANDE2 小时前
Spring Boot--@PathVariable、@RequestParam、@RequestBody
java·spring boot·后端
Treh UNFO2 小时前
Spring Boot环境配置
java·spring boot·后端
NaMM CHIN2 小时前
Spring boot整合quartz方法
java·前端·spring boot
艾莉丝努力练剑2 小时前
【Linux线程】Linux系统多线程(一):线程概念
java·linux·运维·服务器·开发语言·学习·线程
无籽西瓜a2 小时前
【西瓜带你学设计模式 | 第十期 - 外观模式】外观模式 —— 子系统封装实现、优缺点与适用场景
java·后端·设计模式·软件工程·外观模式