Boot:MP|测验|结果封装|异常处理|前后联调|拦截器

MyBatis 注解 SQL

在 Spring Boot 结合 MyBatis-Plus 的企业级开发中,基础的 CRUD 操作已由 BaseMapper 自动接管。然而,面对复杂的单表统计、多表联查或特定的业务 SQL,依然需要开发者手动编写 SQL 语句。

应用场景

MyBatis 提供了 @Select@Insert@Update@Delete 等注解,允许直接在 Mapper 接口的方法上编写 SQL 语句。

  • 何时使用 :当 MyBatis-Plus 提供的 LambdaQueryWrapper 无法满足复杂的查询需求(如多表 JOIN、复杂的 GROUP BY 统计、子查询),且 SQL 逻辑相对简单、不需要动态拼接大量 XML 标签时。
  • 写到何处mapper 包下的接口方法上方。

语法规则与代码模板(增/删/改/查)

java 复制代码
// 接口上方必须标注 @Mapper,告诉 Spring 这是数据访问层的接口
@Mapper
// 继承 BaseMapper<XxxEntity>,保留 MyBatis-Plus 自带的单表增删改查能力
//    这里的 XxxEntity 替换为你项目里具体的实体类,例如 User
public interface XxxMapper extends BaseMapper<XxxEntity> {
    // 括号里直接写原生的 SQL 语句
    // 方法名:按业务含义起名,见名知意,例如 selectByName、countByDeptId
    // 参数列表:方法参数前用 @Param("参数名") 注解,与 SQL 里的 #{参数名} 名字一致
    @Select("SQL语句")
    返回类型 方法名(@Param("参数名") 参数类型 形参名);
    
    // ========== 查 ==========
    @Select("SELECT * FROM 表名 WHERE 字段 = #{参数名}")
    返回类型 selectEntity(@Param("参数名") 参数类型 entity);

    // ========== 增 ==========
    @Insert("INSERT INTO 表名(字段列表) VALUES(#{实体属性1}, #{实体属性2})")
    int insertEntity(实体类 entity);  // 直接传实体时,可省略 @Param

    // ========== 改 ==========
    @Update("UPDATE 表名 SET 字段 = #{属性} WHERE id = #{id}")
    int updateEntity(实体类 entity);

    // ========== 删 ==========
    @Delete("DELETE FROM 表名 WHERE id = #{id}")
    int deleteEntity(@Param("id") Long id);
}

参数绑定规则

  • 单参数(基本类型/字符串) :必须用 @Param("名称"),SQL 中 #{} 使用该名称。

  • 单实体对象参数 :可直接省略 @Param,SQL 中 #{} 写实体类的属性名,MyBatis 通过 getter 取值。

  • 多参数(基本类型 + 实体等) :所有参数均需 @Param 声明。
    多参数示例(基本类型 + 实体混合):
    场景 :更新某个用户的邮箱,但需要同时传入用户 ID(基本类型)和更新信息(实体对象)。

    java 复制代码
    // 错误写法(没有 @Param,MyBatis 无法识别)
    @Update("UPDATE user SET email = #{user.email} WHERE id = #{id}")
    int updateEmail(Long id, User user);  // 报错:BindingException
    
    // 正确写法(所有参数都用 @Param 显式命名)
    @Update("UPDATE user SET email = #{user.email} WHERE id = #{id}")
    int updateEmail(@Param("id") Long id, @Param("user") User user);

    SQL 中引用规则:

    • #{id} → 直接取 @Param("id") 标记的 Long id
    • #{user.email} → 先找到 @Param("user") 标记的 User user,再通过该对象的 getEmail() 取值

示例

  1. 条件查询
java 复制代码
@Mapper
public interface TalentMapper extends BaseMapper<Talent> {

    // 根据城市与学历查询人才列表
    @Select("SELECT * FROM talent WHERE city = #{city} AND education = #{education}")
    List<Talent> findByCityAndEducation(@Param("city") String city, 
                                        @Param("education") String education);
}
  1. 聚合统计(返回 Map)
java 复制代码
// 按城市统计人才数量,结果用 Map 接收(键:城市名,值:数量)
@Select("SELECT city, COUNT(*) AS count FROM talent GROUP BY city")
@MapKey("city")
Map<String, Map<String, Object>> countByCity();
  1. 插入
java 复制代码
@Insert("INSERT INTO talent(name, city, education) VALUES(#{name}, #{city}, #{education})")
int insertTalent(Talent talent);  // 直接传实体,#{} 直接取属性
  1. 更新
java 复制代码
@Update("UPDATE talent SET city = #{city} WHERE id = #{id}")
int updateCity(@Param("id") Long id, @Param("city") String city);
  1. 删除
java 复制代码
@Delete("DELETE FROM talent WHERE id = #{id}")
int deleteById(@Param("id") Long id);

注意事项

  1. SQL 复杂度控制@Select 等注解仅适用于短小、静态的 SQL (通常不超过 10 行)。若 SQL 需要动态 <if><choose> 等标签,或语句长达数十行,必须将 SQL 迁移到同名的 Mapper.xml 文件,以保证可读性和可维护性。
  2. 返回类型选择
操作 注解 常用返回类型 说明
@Insert int 返回受影响行数,也可用 voidboolean
@Delete int 同上
@Update int 同上
查(单条) @Select 实体类 查不到时返回 null
查(多条) @Select List<实体类> 查不到时返回空列表
查(统计值) @Select Long / Integer / BigDecimal 用于 COUNT、SUM、AVG 等
查(单列值列表) @Select List<String>List<Long> 比如只查某一列的所有值
查(聚合分组) @Select List<Map<String, Object>> 每条记录是一个 Map,列名作 key
查(聚合分组,指定 key) @Select + @MapKey("字段名") Map<String, Map<String, Object>> 外层 Map 的 key 是指定字段的值

IDEA 注入 Mapper 接口"报红"机制解析

现象 :在传统 SSM 或 Spring Boot 项目中,使用 @Autowired 注入 Mapper 接口时,IDEA 编辑器经常会在下方标出红色波浪线,提示"Could not autowire. No beans of 'XXXMapper' type found."(无法自动装配,找不到该类型的 Bean)。但项目能够正常编译且运行完美

解决方案与规范

此"报红"属于 IDE 的静态检查误报,不影响任何实际运行。在企业级开发中,通常采用以下方式处理:

  • 方案 A(推荐):调整 IDEA 检查级别
    将光标移至报红的 @Autowired 处,按下 Alt + Enter(Windows)或 Option + Enter(Mac),选择 Autowire for... -> Disable injection point validation 或降低该检查项的严重级别(Severity)为 WarningWeak Warning

  • 方案 B:使用构造器注入(彻底消除红线)
    放弃字段注入(@Autowired),改用 Lombok 的 @RequiredArgsConstructor 进行构造器注入。这不仅是 Spring 官方推荐的注入方式,也能完美绕过 IDEA 的接口注入误报。

    java 复制代码
    @Service
    @RequiredArgsConstructor // Lombok 注解,自动生成包含 final 字段的构造函数
    public class TalentServiceImpl implements TalentService {
    
        // 使用 final 修饰,通过构造函数注入,IDEA 不会报红
        private final TalentMapper talentMapper;
    
        @Override
        public List<Talent> getAll() {
            return talentMapper.selectList(null);
        }
    }

底层原理

  1. Mapper 本质上是一个 Java 接口 ,接口本身无法被实例化(不能 new)。

  2. Spring 容器在启动时,通过 MyBatis 提供的动态代理机制(JDK 动态代理),在运行时(Runtime) 为该接口生成一个代理实现类,并将该代理对象注册到 Spring 容器中。

  3. IDEA 的静态代码分析器在编译期(Compile-time) 扫描代码时,只能看到接口定义,无法感知运行时才会生成的代理类,因此误认为"找不到实现类",从而触发错误提示。

Spring Boot 单元测试与接口契约验证

在企业级项目开发中,代码编写完成后必须经过严格的测试验证。测试主要分为两个维度:

  • 针对业务逻辑层的单元测试
  • 针对表现层(Controller)API 接口的接口契约验证

简单说:单元测试测内部逻辑,接口测试测对外承诺。

Spring Boot 为此提供了 @SpringBootTest 注解,能够自动识别主启动类并加载完整的 Spring 应用上下文。

单元测试

核心机制与语法公式

  • @SpringBootTest :类级注解。标记该类为 Spring Boot 测试类。运行测试时,框架会自动寻找带有 @SpringBootApplication 注解的主启动类,并启动一个为测试准备的 Spring 容器。
  • @Autowired:用于在测试类中直接注入待测试的 Service 或 Mapper 组件。
  • @Test:JUnit 提供的方法级注解,标识该方法为一个独立的测试用例。

语法模板

java 复制代码
// 类级注解:启动 Spring 测试容器
@SpringBootTest
class XxxServiceTest {

    // 注入待测试的组件(Service 或 Mapper)
    @Autowired
    private XxxService xxxService;

    // @Test 标记该方法为测试用例
    @Test
    void testMethodName() {
        // 1. 调用业务方法
        // 2. 用断言验证结果(如 assertNotNull、assertEquals 等)
    }
}

断言

断言 就是 对比 方法执行结果的预期和实际。如果一致,测试通过(绿色);不一致,测试失败(红色),并抛出具体错误信息(可选,但建议)。

显示上在哪里做什么 :在 IDEA 里右键运行测试类,底部会出现测试面板。每个测试方法运行后,断言通过显示绿色对勾 ✅,断言失败显示红色叉号 ❌,并附带你写的提示信息和实际值。不需要盯控制台日志,一眼就能看出哪个方法没过、为什么没过。

常用的 4 个断言

java 复制代码
// 1. 断言不为空:验证查询结果不是 null
assertNotNull(对象, "失败时的提示信息");

// 2. 断言相等:验证两个值是否相等
assertEquals(期望值, 实际值, "失败时的提示信息");

// 3. 断言为真:验证条件成立
assertTrue(条件表达式, "失败时的提示信息");

// 4. 断言异常:验证抛出了指定类型的异常
assertThrows(异常类.class, () -> { 可能抛异常的代码 });

示例

java 复制代码
@Test
void testGetById() {
    Talent talent = talentService.getById(1L);
    
    // 验证查到了数据,不为 null
    assertNotNull(talent, "ID 为 1 的人才不存在,查询结果为 null");
    
    // 验证名字是预期的值
    assertEquals("张三", talent.getName(), "名字不匹配,预期张三,实际:" + talent.getName());
    
    // 验证 ID 大于 0
    assertTrue(talent.getId() > 0, "ID 不大于 0,实际值:" + talent.getId());
}

@Test
void testBusinessException() {
    // 验证传入非法参数时抛出了 BusinessException
    assertThrows(BusinessException.class, () -> {
        talentService.getById(-1L);  // 传入不合法 ID
    }, "传入非法 ID 应抛出 BusinessException");
}

注意 :断言方法内部执行的顺序是线性的。如果第一个断言失败,会直接抛出异常,后续的断言不会执行。 因此,通常把 assertNotNull 放在最前面,确保对象不为空后再断言其属性。

为什么用断言而不是 System.out.println

方式 测试结果 问题
System.out.println 人工看控制台输出判断 100 个测试看不过来,容易漏
assertNotNull 框架自动判断通过/失败 红色标记失败,绿色通过,零人工

一句话:断言是把"人眼判断"交给"机器判断",让测试结果直接显示为通过或失败,不用人工逐个检查日志。

自定义测试方法执行顺序

JUnit 默认按方法名的字典序或随机顺序执行测试,无法保证固定顺序。当需要严格控制执行顺序时(比如先测新增、再测查询、最后测删除),使用 @TestMethodOrder(MethodOrderer.OrderAnnotation.class) 配合 @Order(n) 注解控制,以下模板:

代码模板

java 复制代码
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)  // 声明按 @Order 注解排序
class TalentServiceTest {

    @Autowired
    private TalentService talentService;

    @Test
    @Order(1)  // 数字越小越先执行
    void testSave() {
        // 第一步:新增
    }

    @Test
    @Order(2)
    void testGetById() {
        // 第二步:查询
    }

    @Test
    @Order(3)
    void testDelete() {
        // 第三步:删除
    }
}

规则

  • @TestMethodOrder(...):写在类上,指定排序方式
  • @Order(n):写在方法上,n1, 2, 3...数字越小越先执行
  • 其他排序方式:@TestMethodOrder(MethodOrderer.MethodName.class)按方法名字典序

示例

位置src/test/java 目录下,包路径与主代码一致(如 com.itheima.test0705.service)。

java 复制代码
@SpringBootTest
class TalentServiceTest {

    @Autowired
    private TalentService talentService;  // 注入待测试的 Service

    // 测试:根据 ID 查询单条记录
    @Test
    void testGetById() {
        Talent talent = talentService.getById(1L);  // 调用业务方法
        // 断言:结果不应为 null
        assertNotNull(talent, "查询结果不应为空");
    }

    // 测试:查询全部记录
    @Test
    void testGetAll() {
        List<Talent> list = talentService.list();  // MyBatis-Plus 内置方法,获取所有数据
        assertNotNull(list, "列表不应为空");
        // 断言:结果数量应大于 0
        assertTrue(list.size() > 0, "数据表中应有数据");
    }
}

注意事项

1. 测试类找不到主启动类

  • 现象 :运行时抛出 Unable to find a @SpringBootConfiguration 异常。

  • 原因:测试类所在的包路径与主启动类不一致,Spring 无法自动定位。

  • 解决 :在 @SpringBootTest 中显式指定主启动类。

    java 复制代码
    @SpringBootTest(classes = Test0705Application.class)

2. 测试数据污染数据库

  • 现象:运行新增或修改的测试方法后,数据库中残留测试数据,影响后续测试。

  • 解决 :在测试方法上添加 @Transactional,Spring 测试环境会在方法执行完毕后自动回滚事务,数据库不留痕迹。

    java 复制代码
    @Test
    @Transactional  // 测试结束自动回滚,不污染数据库
    void testSave() {
        talentService.save(new Talent());
    }
  • 如需保留数据 (确认数据正确落库),额外加 @Rollback(false)

    java 复制代码
    @Test
    @Transactional
    @Rollback(false)  // 不回滚,数据保留
    void testSaveAndKeep() { ... }

接口契约验证

核心概念

接口契约验证,是确认 Controller 层暴露出去的 HTTP API 是否按照前后端约定好的规矩来运行------URL 路径对不对、请求方式对不对、返回的 JSON 结构是不是统一的 Result 格式。

验证什么

在前后端分离项目中,后端必须保证每个接口的以下四项与约定一致:

  • URL 路径 :路径是否符合 RESTful 规范,如 /api/talents/api/talents/1
  • HTTP 方法:GET、POST、PUT、DELETE 是否正确
  • 请求参数:路径变量、查询参数、请求体 JSON 的字段名和类型是否与约定一致
  • 响应结构 :返回值是否统一包装在 Result 中,codemessagedata 三个字段是否完整

各操作的验证标准

新增(POST)

  • 请求:POST
  • URL 路径示例:/api/talents
  • 请求体:JSON 格式的 DTO 数据,Content-Type: application/json
  • 预期响应:{"code":200, "message":"success", "data":null}

修改(PUT)

  • 请求:PUT
  • URL 路径示例:/api/talents
  • 请求体:JSON 格式(需包含主键 ID),Content-Type: application/json
  • 预期响应:{"code":200, "message":"success", "data":null}

删除(DELETE)

  • 请求:DELETE
  • URL 路径示例:/api/talents/1
  • 请求体:无
  • 预期响应:{"code":200, "message":"success", "data":null}

查询单个(GET)

  • 请求:GET
  • URL 路径示例:/api/talents/1
  • 请求体:无
  • 预期响应:{"code":200, "message":"success", "data":{...}}data 中为查询到的对象

查询全部(GET)

  • 请求:GET
  • URL 路径示例:/api/talents
  • 请求体:无
  • 预期响应:{"code":200, "message":"success", "data":[...]}data 中为数组

示例(以 Postman 为例)

1. 验证新增接口

在 Postman 里做这些操作:

  • 请求方式选 POST

  • URL 栏输入 http://localhost:8080/api/talents

  • 点击 Headers,添加两行:

    • token : test123
    • Content-Type : application/json
  • 点击 Body,选 raw,右侧下拉选 JSON,然后在文本框里粘贴:

    json 复制代码
    {
        "name": "测试人才",
        "phone": "13912345678",
        "education": "硕士",
        "city": "南昌"
    }
  • 点击 Send 发送请求。

验证(就是看返回结果):

  • 看右上角,HTTP 状态码是不是 200 OK
  • 看下方Body栏,返回的 JSON,code 是不是 200
  • 打开数据库客户端,查 talent 表,看是不是多了一条数据

2. 验证查询单个接口

  • 新建一个请求,方式选 GET
  • URL 栏输入 http://localhost:8080/api/talents/1
  • Headers 里加 token : test123
  • 不需要 Body,直接点 Send

验证:

  • 看返回的 JSON 里 data 是不是一个对象(不是数组)
  • data 里的字段名是不是 idnamephoneeducationcity(和前端约定好的名字一致)
  • data.id 是不是 1

注意事项

1. 返回值未包装在 Result 中

  • 现象:Controller 方法直接返回了实体类、Listboolean,导致前端收到的是 {...}[...]true/false,没有 codemessage 字段。
  • 后果:前端无法通过统一的 res.code 判断请求是否成功,解析逻辑直接崩溃。
  • 正确做法:所有 Controller 方法的返回值必须用 Result<T> 包装。查询为空时返回 Result.success(null),业务异常由全局异常处理器统一返回 Result.error(...)

2. HTTP 状态码和业务状态码混淆

  • 现象:业务出错时(如"人才已存在"),后端直接抛出未处理的异常,导致 HTTP 状态码变成 500;或者手动将 HTTP 状态码设为 400。
  • 问题:两种做法都破坏了统一的响应格式。前者让前端误以为服务器崩溃,后者让前端需要同时解析 HTTP 状态码和响应体里的业务状态码,增加处理复杂度。
  • 正确做法 :团队内部统一约定一种方式,前后端保持一致即可。常见的有两种策略:
    • 策略 A :所有接口均返回 HTTP 200,业务状态完全由 Result 内部的 code 字段区分。前端只需解析响应体即可判断成败。
    • 策略 B :充分利用 HTTP 状态码表达语义(201 创建成功、400 参数错误、404 资源不存在),同时响应体仍保留 Result 结构。前端同时处理 HTTP 状态码和业务状态码。
  • 关键原则 :无论采用哪种策略,必须全项目统一 ,不能同一项目的不同接口使用不同规则。Result 包装层应始终存在,业务异常交给全局异常处理器统一处理,而不是在 Controller 里手动干预 HTTP 状态码。

统一结果封装 Result

作用

所有 Controller 方法返回的数据都必须用 Result 包装,确保前端收到的 JSON 结构始终统一为 {code, msg, data} 三个字段,不会出现有时是对象、有时是数组、有时是布尔值的情况。

代码

放在 com.example.demo.common 包下:

java 复制代码
@Data
public class Result<T> {
    // 业务状态码:200 表示成功,非 200 表示各类失败
    private Integer code;
    // 提示信息:成功时为 "操作成功",失败时为具体错误原因
    private String msg;
    // 业务数据:查询成功时存放返回数据,无数据或失败时为 null
    private T data;

    // 私有化构造方法,强制通过静态工厂方法创建对象
    private Result() {}

    // 成功响应(携带数据):用于查询操作
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    // 成功响应(无数据):用于新增、修改、删除操作
    public static <T> Result<T> success() {
        return success(null);
    }

    // 失败响应(自定义状态码和提示信息)
    public static <T> Result<T> error(Integer code, String msg) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMsg(msg);
        return result;
    }

    // 失败响应(默认 500 服务器内部错误)
    public static <T> Result<T> error(String msg) {
        return error(500, msg);
    }
}

写法说明

1. 为什么用泛型 <T>

data 字段的类型是 T,意味着放什么类型的数据进去,取出来就是什么类型,不需要强转。例如 Result.success(userVO) 返回的就是 Result<UserVO>,前端 data 里就是 UserVO 的字段。

2. 为什么用静态工厂方法

Controller 里直接写 Result.success(data) 就能得到一个包装好的对象,不需要 new Result() 再逐行 set。代码更短,意图更清晰。

3. 状态码常量化

项目中统一维护一个 Code 接口,避免各处硬编码数字:

java 复制代码
public interface Code {
    Integer SUCCESS = 200;          // 成功
    Integer BAD_REQUEST = 400;      // 参数错误
    Integer UNAUTHORIZED = 401;     // 未登录或 Token 过期
    Integer FORBIDDEN = 403;        // 无权限
    Integer NOT_FOUND = 404;        // 资源不存在
    Integer INTERNAL_ERROR = 500;   // 服务器内部错误
}

使用时:

java 复制代码
Result.error(Code.NOT_FOUND, "人才信息不存在");
Result.error(Code.BAD_REQUEST, "参数校验失败");

不需要注入,不需要继承,创建了就能任意位置直接用。项目任何地方写 Code.SUCCESS 就能拿到 200。

Controller 使用示例

java 复制代码
@RestController
@RequestMapping("/api/talents")
public class TalentController {

    @Autowired
    private TalentService talentService;

    // 新增:无数据返回
    @PostMapping
    public Result<Void> save(@RequestBody @Validated TalentDTO dto) {
        talentService.save(dto);
        return Result.success();
    }

    // 查询单个:携带数据
    @GetMapping("/{id}")
    public Result<Talent> getById(@PathVariable Long id) {
        Talent talent = talentService.getById(id);
        if (talent == null) {
            return Result.error(Code.NOT_FOUND, "人才信息不存在");
        }
        return Result.success(talent);
    }

    // 查询全部:携带集合数据
    @GetMapping
    public Result<List<Talent>> getAll() {
        List<Talent> list = talentService.list();
        return Result.success(list);
    }
}

注意事项

1. Result 必须配合全局异常处理器使用

Controller 里用了 Result 包装,但如果参数校验失败、数据库报错等异常未被处理,前端仍然会收到 Tomcat 默认的错误页面或杂乱 JSON,统一格式被打破。

正确做法:所有异常统一由 @RestControllerAdvice 全局异常处理器拦截 ,转为 Result.error(...) 返回。Controller 里只用 Result.success(),错误全部交给异常机制处理。

2. 不在 Controller 里写业务判断

不要在 Controller 里写:

java 复制代码
boolean flag = service.save(dto);
if (!flag) {
    return Result.error("新增失败");
}

正确做法:Service 层遇到业务错误直接 throw new BusinessException("原因"),全局异常处理器自动转为 Result.error Controller 只负责接收参数、调用 Service、返回 Result,不做业务判断。

3. data 为 null 时序列化行为不变

Result.success(null) 返回的 JSON 中 data 字段值为 null,这是正常的。前端判断时看 code,不看 data 是否为 null。data 为 null 仅表示本次操作无数据需要返回(如删除操作),不代表失败。

4. 内部 data 的字段序列化规则不变

Result 只是外层包装,不干预 data 内部对象的序列化。如果 data 中的字段有 @JsonFormat 日期格式化、@JsonSerialize 自定义序列化等注解,依然正常生效。

在传统的 SSM 教学文档中,为了演示异常分类的概念,通常会在 Service 层通过 try-catch 捕获底层异常并手动包装为 SystemException 抛出。在现代 Spring Boot 企业级实战中,这种做法已被视为反模式(Anti-Pattern),因此在之前的整理中将其与未知异常合并,统一交由全局兜底处理。

省略(合并)的核心理由如下:

  1. 违背 AOP 与代码整洁原则 :在每一个 Service 方法中编写 try-catch 来包装系统异常,会导致业务代码极度臃肿,核心业务逻辑被大量的异常包装代码淹没。
  2. 破坏声明式事务 (@Transactional):若手动捕获异常后包装抛出,极易因处理不当导致 Spring 事务管理器无法正确感知原始异常,进而引发事务未回滚的严重生产事故。
  3. 全局拦截机制已足够完善 :Spring 框架底层针对各类系统故障(如数据库宕机、SQL 语法错误、网络超时)均会抛出特定的运行时异常(如 DataAccessException)。通过全局异常处理器直接拦截这些底层系统异常或统一的 Exception.class,配合日志框架(如 SLF4J + Logback)记录堆栈并接入告警系统,即可完美实现"安抚用户并通知运维"的需求,无需在业务层手动转换。

全局异常处理

为什么需要全局异常处理

项目中各层代码运行时都可能出错:Service 层校验不通过、数据库连接超时、代码出现空指针异常等等。如果不对这些错误做统一处理,后端会直接把异常堆栈信息返回给前端。前端收到的是满屏英文报错,用户体验极差,同时还会暴露服务器内部的包路径、表名等敏感信息。

全局异常处理的作用,就是 把分散在各层的错误统一拦截下来,包装成前端能识别的 JSON 格式返回,同时根据异常类型决定要不要记录日志、要不要通知运维人员

异常分类

项目中的异常按来源分为三类,处理方式各不相同:

1. 业务异常

  • 是什么:用户操作不当数据不符合业务规则,比如用户名已存在、人才重复申报、密码格式错误。
  • 处理方式:把具体的错误原因直接返回给前端,提醒用户修改。不需要记录日志,也不需要通知运维人员。

2. 系统异常

  • 是什么:调用外部服务失败或系统组件故障,比如第三方 AI 接口超时、短信网关断开、数据库短暂抖动。
  • 处理方式:向前端返回安抚性话术(如"系统繁忙,请稍后再试"),隐藏技术细节。需要记录日志,供后续排查。
  • 控制告警:系统每天可能有大量边缘服务的小抖动,需要用 needAlert 标识来控制:核心链路(如支付、核心数据入库)故障时触发告警,通知运维人员处理,边缘服务(如非核心的天气查询)故障时仅记日志。

3. 未知异常

  • 是什么:代码级 Bug,比如空指针异常、数组越界、SQL 语法错误。
  • 处理方式:向前端返回统一提示(如"系统内部错误,请联系管理员"),必须记录完整堆栈日志,并强制触发告警。

核心注解

Spring Boot 提供了两个注解来实现全局异常处理:

  • @RestControllerAdvice:加在类上,标识这个类是全局异常处理器。它会拦截所有 Controller 层抛出的异常,并把处理方法返回的对象自动转为 JSON 写入响应体。
  • @ExceptionHandler(异常类.class):加在方法上,指定这个方法负责处理哪种类型的异常。参数里写什么异常类,这个方法就只处理什么异常类。

语法模板

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(特定异常类.class)
    public Result handleXxxException(特定异常类 e) {
        // 从异常对象中取出错误信息
        // 用 Result.error 包装后返回
        return Result.error(错误码, 错误信息);
    }
}

代码实现

自定义异常类

异常类的作用是让 Service 层能把不同的错误包装成不同类别的"错误包裹" 抛出去,全局异常处理器根据包裹的类别做不同的处理。

放到哪里 :在 主包.exception 包下新建这两个类。

为什么必须继承 RuntimeException

  • 继承 RuntimeException 后,在 Service 层抛出异常时不需要写 try-catch 包裹,代码更干净。
  • Spring 的 @Transactional 事务管理器能识别 RuntimeException,一旦抛出就自动回滚数据库事务。如果继承的是非运行时异常,事务不会回滚。

业务异常类

java 复制代码
package com.example.demo.exception;

public class BusinessException extends RuntimeException {

    private final Integer code;   // 业务状态码,由 Code 接口统一管理

    /**
     * 构造方法:传入状态码和错误信息
     * 使用场景:需要明确指定错误码时
     */
    public BusinessException(Integer code, String message) {
        super(message);     // 把错误信息存入父类,让 getMessage() 能取到
        this.code = code;
    }

    /**
     * 构造方法:只传错误信息,状态码默认 400(参数错误)
     * 使用场景:大部分参数校验不通过的场景
     */
    public BusinessException(String message) {
        this(400, message);       // 调用上面的双参构造,code 传 400
    }

    public Integer getCode() {
        return code;
    }
}

this(...) 是 Java 中的一个特殊语法,叫构造器调用。它只能写在构造方法的第一行,作用是调用本类的另一个构造方法,简化相同逻辑。

系统异常类

java 复制代码
package com.example.demo.exception;

public class SystemException extends RuntimeException {

    private final Integer code;
    private final boolean needAlert;  // 是否需要触发运维告警(发钉钉/短信)

    /**
     * 构造方法:同时指定状态码、错误信息、是否告警
     */
    public SystemException(Integer code, String message, boolean needAlert) {
        super(message);
        this.code = code;
        this.needAlert = needAlert;
    }

    /**
     * 构造方法:只传错误信息,默认 code=500 且 needAlert=true
     * 使用场景:核心链路故障,需要立刻通知开发人员
     */
    public SystemException(String message) {
        this(500, message, true);      // 调用三参构造,needAlert 传 true
    }

    public Integer getCode() {
        return code;
    }

    public boolean isNeedAlert() {
        return needAlert;
    }
}

needAlert 标识详解

needAlertSystemException 类中的一个 boolean 类型字段。它只有两个值:truefalse。这个字段决定了全局异常处理器在捕获到 SystemException 后,要不要通知运维人员

含义 触发什么操作
true 需要告警 记录日志 + 发送钉钉/短信/邮件通知运维人员
false 不需要告警 只记录日志,不发送任何通知

为什么要这样设计 :公司项目上线后,系统每天要处理大量请求。其中有些外部服务是核心链路(比如支付接口、核心数据入库),出了问题必须立刻让开发人员知道,半夜也要爬起来修。但有些外部服务是边缘功能(比如查询天气、发送无关紧要的短信通知),偶尔超时或失败是很正常的,如果每次都发告警,运维人员一天能收到几百条消息,真正重要的告警就会被淹没。这就是"告警风暴"。所以设计上,needAlert 就是用来区分的:核心链路出问题设 true,边缘功能出问题设 false。

与其他层的配合

在 Service 层怎么传值

java 复制代码
// 场景一:核心业务出错 → 用单参构造,needAlert 默认为 true
throw new SystemException("AI简历解析服务超时");
// 等同于:throw new SystemException(500, "AI简历解析服务超时", true);

// 场景二:边缘业务出错 → 显式传 false,避免告警风暴
throw new SystemException(500, "短信发送失败", false);

在全局异常处理器里怎么用

java 复制代码
if (e.isNeedAlert()) {
    // needAlert 为 true → 发送告警
    alertService.sendAlert("核心系统异常", e.getMessage());
}
// needAlert 为 false → 不发送告警,只记日志
全局异常处理器

放到哪里 :在 主包.exception 包下新建 GlobalExceptionHandler 类。

核心逻辑 :通过三个 @ExceptionHandler 方法分别处理三类异常,各自返回统一的 Result 格式。

java 复制代码
package com.example.demo.exception;

@RestControllerAdvice  // 声明这是全局异常处理器,拦截所有 Controller 抛出的异常
public class GlobalExceptionHandler {

    // 日志对象,用于记录系统级错误的堆栈信息
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

     // 处理业务异常:不记日志、不告警,直接返回错误信息给前端
    @ExceptionHandler(BusinessException.class)
    public Result handleBusinessException(BusinessException e) {
        // 从异常对象中取出 code 和 message,原样返回给前端
        return Result.error(e.getCode(), e.getMessage());
    }

    // 处理系统异常:记录日志,根据 needAlert 决定是否告警,向前端返回安抚话术
    @ExceptionHandler(SystemException.class)
    public Result handleSystemException(SystemException e) {
        // 记录日志,方便后续排查
        log.error("系统异常:{}", e.getMessage(), e);

        // 根据 needAlert 标识决定是否触发告警
        if (e.isNeedAlert()) {
            // 核心链路故障,发钉钉/短信通知运维人员
            sendAlert("核心系统异常", e.getMessage());
        }
        // needAlert 为 false 时,只记日志,不告警,避免边缘服务抖动造成告警风暴

        // 向前端返回安抚话术,不暴露技术细节
        return Result.error(e.getCode(), "系统繁忙,请稍后再试");
    }

    // 兜底处理未知异常:记录完整堆栈、强制告警,返回统一提示
    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        // 记录完整堆栈日志,这是排查 Bug 的关键依据
        log.error("未知异常:", e);

        // 未知异常属于代码级 Bug,必须强制告警
        sendAlert("严重Bug", e.getMessage());

        // 向前端返回统一提示,绝对不能把 e.getMessage() 直接返回(可能包含敏感信息)
        return Result.error(Code.INTERNAL_ERROR, "系统内部错误,请联系管理员");
    }

    // 模拟告警服务
    private void sendAlert(String title, String message) {
        // 实际代码中这里对接钉钉/企业微信/邮件等告警通道
        // 此处仅做占位示例
        log.warn("触发告警:{} - {}", title, message);
    }
}

三个处理方法的调用优先级:

  1. 抛出 BusinessException 时,进入 handleBusinessException
  2. 抛出 SystemException 时,进入 handleSystemException
  3. 抛出其他异常(如 NullPointerExceptionSQLException)时,前两个都不匹配,最终进入 handleException 兜底。

Spring 按照异常类型从具体到一般的顺序进行匹配,先匹配专门处理的异常,找不到时才使用 Exception.class 方法。

Service 层如何抛出异常

在 Service 层的业务代码中,遇到不同情况要抛出不同类型的异常。以下示例展示三种场景的写法:

java 复制代码
@Service
public class TalentServiceImpl implements TalentService {

    @Autowired
    private TalentMapper talentMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)  // 开启事务,任何异常都回滚
    public void applySubsidy(Long talentId) {

        // 场景一:业务校验不通过 → 抛 BusinessException
        // 这是用户层面的错误,直接告诉用户原因即可
        Talent talent = talentMapper.selectById(talentId);
        if (talent == null) {
            throw new BusinessException("申报失败:该人才信息不存在");
        }
        if (talent.getStatus() == 1) {
            throw new BusinessException("申报失败:该人才已申报过补贴");
        }

        // 场景二:调用核心外部服务失败 → 抛 SystemException(needAlert=true)
        // AI 简历解析是核心业务,失败时必须通知开发人员
        try {
            String result = callAiResumeParseService(talent.getFileUrl());
        } catch (TimeoutException e) {
            // 用单参构造,默认 needAlert=true
            throw new SystemException("AI简历解析服务超时");
        }

        // 场景三:调用边缘外部服务失败 → 抛 SystemException(needAlert=false)
        // 短信通知是辅助功能,偶尔发送失败很正常,不需半夜告警
        try {
            sendSms(talent.getPhone(), "您的申报已受理");
        } catch (Exception e) {
            // 用三参构造,显式指定 needAlert=false,避免告警风暴
            throw new SystemException(500, "短信发送失败", false);
        }

        // 执行数据库操作
        // 如果这里数据库宕机,Spring 底层抛出 DataAccessException
        // 该异常不属于 BusinessException 或 SystemException
        // 会被 GlobalExceptionHandler 的兜底方法捕获并强制告警
        talentMapper.updateStatus(talentId, 1);
    }
}

注意 :在带 @Transactional 的方法里,严禁try-catch 捕获异常后不重新抛出。因为如果 catch 了异常却没有 throw,Spring 事务管理器会认为方法正常执行完毕,从而提交事务。这会导致数据库操作只执行了一半却被永久保存,产生严重脏数据。

完整工作流程

当 Service 层抛出异常后,Spring MVC 的处理顺序如下:

  1. 异常在 Service 层被 throw 抛出,沿调用栈向上穿透 Controller 层。
  2. Controller 没有 catch 这个异常,异常继续向上抛给 DispatcherServlet
  3. DispatcherServlet 发现有 @RestControllerAdvice 标记的全局异常处理器。
  4. Spring 根据异常对象的实际类型,在 GlobalExceptionHandler 中查找能处理该异常的方法。匹配规则是从具体到一般 ,先找专门处理 BusinessExceptionSystemException 的方法,都没找到才走兜底的 Exception.class 方法。
  5. 执行匹配到的处理方法,从异常对象中取出错误信息,构造 Result.error(...) 并返回。
  6. Spring 把 Result 对象自动序列化为 JSON 写入响应体,前端收到统一格式的错误响应。

注意事项

1. 事务回滚问题

在带 @Transactional 的方法中捕获异常但不重新抛出,是初学者最容易犯的严重错误。

错误写法:

java 复制代码
@Transactional
public void save(Talent talent) {
    try {
        talentMapper.insert(talent);
    } catch (Exception e) {
        e.printStackTrace();  // 异常被吞掉了,Spring 不知道出错了
        // 事务管理器会提交事务,产生脏数据
    }
}

正确写法:遇到无法恢复的错误,必须抛出 RuntimeException,让事务管理器感知到异常并回滚。

2. 敏感信息泄露

在兜底的 handleException 方法中,绝不能e.getMessage() 直接返回给前端。系统异常的消息里可能包含数据库表名、SQL 语句、服务器内网 IP 等敏感信息,直接暴露会造成安全漏洞。必须返回"系统内部错误,请联系管理员"这样的统一话术。

3. 包路径问题

GlobalExceptionHandler 类必须放在主启动类所在包的同级或子级包 下。@SpringBootApplication 默认扫描主启动类所在的包及其子包,如果全局异常处理器放错位置,Spring 扫描不到,整个异常处理机制就失效了。

前后台协议联调

在前后端分离的企业级项目中,后端提供 RESTful API 与统一响应契约(Result),前端通过 Ajax(如 Axios)异步调用接口并根据响应状态驱动 UI 更新。理解这一完整的联调闭环,是后端开发者设计合理接口、排查前后端交互问题的基础。

静态资源在 Spring Boot 中的标准部署

  • 标准存放位置 :所有前端静态资源(如 Vue 打包后的 dist 目录内容,或原生 HTML/JS 文件)应直接放置在 src/main/resources/static/ 目录下。
  • 自动放行机制 :Spring Boot 底层自动配置了资源处理器,放置在 staticpublicresources 目录下的文件会被自动放行,无需编写任何 Java 配置类。
  • 访问路径 :假设文件位于 src/main/resources/static/pages/books.html,启动项目后直接通过 http://localhost:8080/pages/books.html 即可访问。

接口联调的核心:基于 Result 契约的状态驱动

前后端联调的核心在于后端返回的数据结构必须与前端解析逻辑严格匹配 。前端不直接判断 HTTP 状态码(如 200、404、500),而是统一读取响应体中的 Result 对象,根据内部的 code 字段来决定 UI 的反馈行为。

状态驱动矩阵

后端返回 Result.code 业务含义 前端 UI 反馈动作
200 (或约定的成功码) 业务操作成功 关闭弹窗、刷新列表、弹出"操作成功"提示(如 $message.success
400 (参数校验失败) 客户端传参错误 保持弹窗开启、在表单对应字段下方标红显示错误信息
500 / 600 (业务/系统异常) 业务阻断或系统故障 弹出"操作失败"或具体的错误提示(如 $message.error),不刷新列表

后端数据层与服务层的返回值规范

MyBatis-Plus 的极简返回值

在数据持久层开发中,判断一条 SQL 语句(如 INSERT、UPDATE、DELETE)是否执行成功,是业务逻辑闭环的关键。

在 Spring Boot 结合 MyBatis-Plus 的项目中,框架底层已自动封装了行数判断逻辑。IService 接口提供的 saveupdateByIdremoveById 等方法直接返回 boolean,彻底消除了 Service 层的冗余转换代码。

传统 MyBatis 的痛点

在原生 MyBatis 中,Mapper 接口的增删改方法默认返回 int 类型,代表数据库受影响的行数 。Service 层需要将其转换为 boolean 供 Controller 判断。

开发者必须在 Service 层手动编写判断逻辑:

java 复制代码
// 传统写法:必须手动判断影响行数是否大于0
int rows = bookDao.save(book);
return rows > 0; 

这种写法导致 Service 层充斥着大量毫无业务价值的 > 0 判断代码,增加了代码的冗余度。

MyBatis-Plus 的极简封装

MyBatis-Plus 提供的 IService 接口(及其实现类 ServiceImpl)在底层拦截了 MyBatis 的执行结果,并自动完成了 > 0 的转换。

  • save(T entity) :底层执行 INSERT,自动判断影响行数,返回 boolean
  • updateById(T entity) :底层执行 UPDATE,自动判断影响行数,返回 boolean
  • removeById(Serializable id) :底层执行 DELETE,自动判断影响行数,返回 boolean

核心意义:Service 层只需关注"是否成功"这一布尔结果,无需再关心底层数据库返回的具体行数,使业务代码更加纯粹。

示例

以下代码展示了在 Spring Boot 结合 MyBatis-Plus 的环境中,Service 层与 Controller 层如何利用这一机制进行极简交互。

Service 层:极简的持久化调用

在 Service 层,直接调用 IService 提供的方法,接收 boolean 结果,彻底告别 > 0 判断。

java 复制代码
@Service
public class TalentServiceImpl extends ServiceImpl<TalentMapper, Talent> implements TalentService {

    // 场景 1:基础的新增操作
    @Override
    public boolean addTalent(Talent talent) {
        // MyBatis-Plus 底层已实现:执行 INSERT 后自动判断影响行数并返回 true/false
        // Service 层直接调用 save 方法,无需再编写 baseMapper.insert(talent) > 0 的逻辑
        return save(talent); 
    }

    // 场景 2:结合业务校验的更新操作
    @Override
    public boolean updateTalent(Talent talent) {
        // 业务校验:例如检查该人才是否允许修改
        if ("锁定".equals(talent.getStatus())) {
            throw new BusinessException("该人才信息已被锁定,无法修改");
        }
        
        // 直接返回 updateById 的 boolean 结果,底层自动判断 UPDATE 语句是否影响了数据行
        return updateById(talent);
    }
}

Controller 层:基于布尔值的契约响应

Controller 层接收到 Service 层返回的 boolean 值后,可以直接利用其构建统一的 Result 响应契约。

java 复制代码
@RestController
@RequestMapping("/api/talents")
public class TalentController {

    @Autowired
    private TalentService talentService;

    @PostMapping
    public Result<Void> add(@RequestBody @Validated TalentDTO dto) {
        Talent talent = new Talent();
        BeanUtils.copyProperties(dto, talent);
        
        // 直接接收 Service 层返回的 boolean 值
        boolean success = talentService.addTalent(talent);
        
        // 基于布尔值构建统一响应
        if (success) {
            return Result.success();
        }
        // 若底层影响行数为0(例如触发了数据库的某些特殊拦截器导致未插入),返回失败契约
        return Result.error(Code.SAVE_ERR, "数据保存失败,请检查数据库状态");
    }
}

前后端联调契约与 F12 抓包排错指南

在前后端分离项目中,前后端联调的本质是HTTP 报文的契约校验。后端开发者无需精通前端框架,但必须会用浏览器 F12 开发者工具的 Network 面板来分析 HTTP 请求与响应。这是快速界定前后端责任归属、定位 Bug 的核心技能。

核心概念与联调矩阵

前后端交互完全依赖 HTTP 协议。后端只需关注两件事:

  1. 请求报文是否符合 Controller 的接收规范。
  2. 响应报文 是否符合约定的 Result 契约。

遇到问题时,后端开发者应指导前端(或自行)打开浏览器 F12,进入 Network 面板,找到状态码异常的那个请求,按以下对照表进行排查。

前端现象 F12 排查切入点 后端代码自查点
前端提示 404 / 405 查看 Headers(标头) 标签页的 Request URL 和 Request Method 检查 Controller 类上 @RequestMapping("路径") 和方法上的 @PostMapping("路径") 拼接是否正确;动词是否严格匹配。
后端接收参数全为 null 查看 Payload 标签页(JSON)或 Form Data 标签页 检查前端 JSON 的 Key 是否与后端 DTO 的属性名完全一致;检查方法参数是否遗漏 @RequestBody@RequestParam
前端提示"系统异常"或页面崩溃 查看 Response 标签页返回的内容 检查全局异常处理器 (@RestControllerAdvice) 是否生效。若响应体是 Tomcat 的 HTML 错误页或 Java 堆栈信息,说明异常未被统一处理,破坏了 Result 契约。
前端提示"操作成功"但数据库无变化 查看 Response 标签页中返回的 JSON,其 code 是否为 200。 检查 Service 或 DAO 层是否盲目 return true,而未根据数据库真实影响行数进行判断。若数据库返回影响 0 行,应返回失败码或抛出异常。

F12 抓包排查标准工作流

当联调遇到问题时,按照以下三个步骤,在 F12 的 Network 面板中排查:

校验请求路由 (Headers 标签页)
  • 看哪里 :点击具体的 HTTP 请求,在右侧 Headers 标签页最下方的 General 区域。
  • 看什么
    • Request URL :核对完整的 URL 路径。例如,前端请求 http://localhost:8080/api/talents,后端 Controller 的类和方法注解拼接后必须完全匹配。
    • Request Method :核对 HTTP 方法(GET、POST、PUT、DELETE)。如果前端用 POST 请求,后端对应方法必须是 @PostMapping,否则会报 405 错误。
校验请求参数 (Payload 标签页)
  • 看哪里 :点击具体的 HTTP 请求,选择 Payload 标签页(旧版浏览器可能叫 Request Payload)。
  • 看什么
    • 这里展示前端实际发送的 JSON 字符串。
    • 核对 Key 是否匹配 :JSON 中的 Key(如 "talentName")必须与后端 DTO 类中的属性名(talentName)完全一致,包括大小写。
    • 核对注解是否正确
      • 若为 JSON 数据,后端方法参数前必须有 @RequestBody
      • 若为 URL ?key=value 形式或表单数据,后端方法参数前必须有 @RequestParam
校验响应契约 (Response 标签页)
  • 看哪里 :点击具体的 HTTP 请求,选择 Response 标签页。
  • 看什么
    • 查看后端实际返回的响应体内容。
    • 格式校验 :无论业务成功还是失败,响应体必须是标准的 JSON 格式,且包含三个字段:codemsgdata。例如:{"code":200, "msg":"操作成功", "data":null}
    • 异常兜底校验 :如果响应体中显示的是大段 Java 异常堆栈(如 java.lang.NullPointerException)或 Tomcat 的 HTML 错误页面,说明异常没有被 @RestControllerAdvice 全局异常处理器捕获,直接穿透到了前端。这是后端代码的严重问题。

拦截器

在 Spring MVC 架构中,拦截器(Interceptor)是一种基于 AOP(面向切面编程)思想的动态拦截机制。它允许在请求到达 Controller 方法之前、之后以及视图渲染完成后,插入自定义的业务逻辑。在 Spring Boot 前后端分离项目中,拦截器是构建系统安全防线(如登录鉴权、权限校验)的核心组件。

核心概念与应用场景

拦截器与过滤器的本质区别

虽然拦截器(Interceptor)与过滤器(Filter)在功能上相似,但两者在归属层级与能力边界上存在本质差异:

  • 过滤器 (Filter):属于 Servlet 规范级别。由 Tomcat 等 Web 容器直接管理,能够拦截所有进入服务器的请求(包括 HTML、CSS、JS 等静态资源)。但它无法直接使用 Spring 容器中的 Bean(如无法直接注入 Redis 工具类或 Service)。
  • 拦截器 (Interceptor) :属于 Spring MVC 框架级别。由 Spring 容器管理,仅拦截进入 DispatcherServlet 的动态请求 (默认不拦截静态资源)。其最大优势是能够无缝使用 Spring 容器中的所有 Bean,非常适合处理复杂的业务鉴权逻辑。

何时使用

  • 登录鉴权:校验请求头中的 Token(如 JWT)是否合法,判断用户是否处于登录状态。
  • 权限校验:结合 RBAC(基于角色的访问控制)模型,校验当前用户是否具备访问特定接口的权限。
  • 操作日志记录:在请求处理前后记录用户的操作行为、IP 地址及接口耗时。
  • 防重复提交:结合 Redis 分布式锁,防止用户在短时间内对同一接口进行恶意重复点击。

语法公式与代码编写位置

在 Spring Boot 中,配置拦截器必须通过实现 WebMvcConfigurer 接口来完成。

代码编写位置

  1. 拦截器类 :通常放置在项目的 interceptor 包下,需实现 HandlerInterceptor 接口,并添加 @Component 注解交由 Spring 容器管理。
  2. 配置注册类 :通常放置在项目的 config 包下(如 WebMvcConfig),需实现 WebMvcConfigurer 接口,重写 addInterceptors 方法。

语法公式

java 复制代码
// 1. 定义拦截器
@Component
public class XxxInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(...) { ... }
}

// 2. 注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(拦截器实例)
                .addPathPatterns("拦截路径")
                .excludePathPatterns("放行路径");
    }
}

示例

以下代码展示了如何在项目中实现一个标准的"Token 登录鉴权拦截器",并将其注册到 Spring MVC 容器中。

1. 何时拦、验证逻辑------编写鉴权拦截器类

写到何处主包.interceptor.LoginInterceptor

java 复制代码
package 主包.interceptor;

@Component // 必须添加此注解,将拦截器注册为 Spring Bean,以便后续在配置类中注入或直接 new 出来
public class LoginInterceptor implements HandlerInterceptor {

    // 核心方法:在 Controller 方法执行前触发
    // @return true 放行,请求继续向后执行;false 拦截,请求到此为止
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 1. 从请求头中获取前端传递的 Token
        String token = request.getHeader("token");

        // 2. 校验 Token 是否为空
        if (token == null || token.trim().isEmpty()) {
            // 手动设置 HTTP 状态码为 401 (Unauthorized),表示未授权
            response.setStatus(401);
            response.setContentType("application/json;charset=utf-8");
            // 向前端写入统一契约的错误 JSON 响应
            response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期,请重新登录\"}");
            // 返回 false,阻断请求,Controller 方法不会执行
            return false;
        }

        // 3. 校验 Token 合法性(此处省略具体的 JWT 解析与 Redis 校验逻辑,用硬编码模拟)
        boolean isValid = checkToken(token);
        if (!isValid) {
            response.setStatus(401);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":401,\"msg\":\"Token无效\"}");
            return false;
        }

        // 4. 校验通过,放行请求
        // 进阶操作:此处可将解析出的用户ID存入 ThreadLocal,供后续 Controller/Service 获取当前登录用户信息
        return true;
    }

    // 模拟 Token 校验方法,实际项目中此处应解析 JWT 或查询 Redis
    private boolean checkToken(String token) {
        return "admin_token_123".equals(token);
    }
}

解析:为什么拦截器里要"手写 JSON" (response)而不是"抛出异常"(throw)?

在 Controller 或 Service 中,遇到错误我们通常直接 throw new BusinessException(401, "未登录"),让 @RestControllerAdvice 全局异常处理器去捕获并转为 JSON。 但在拦截器中,强烈建议手动操作 response 写入 JSON(如上述代码所示)。

原因 :拦截器(Interceptor)的执行时机在 DispatcherServlet 分发到 Controller 之前 。而 @RestControllerAdvice 主要是用来捕获 Controller 及其内部调用链 抛出的异常。如果在拦截器中直接抛出业务异常,Spring MVC 的默认异常处理机制可能无法正确将其转化为约定的 JSON 响应(通常会走到 Tomcat 的默认错误页或 Spring Boot 的 BasicErrorController),导致前端收到的不是统一的 Result 格式。因此,在拦截器中直接操作 HttpServletResponse 手动写入 JSON 是最稳妥、最标准的兜底方案

2. 拦哪些------注册拦截器并配置路径规则

拦到了就要去拦截器类进行验证,决定是否放行。

拦截策略:默认拒绝,显式允许

写到何处主包.config.WebMvcConfig

java 复制代码
package 主包.config;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    // 若拦截器类中需要注入其他 Spring Bean(如 RedisTemplate),可通过 @Autowired 注入拦截器实例
    // 此处为简单演示,直接 new 出拦截器对象
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                // 拦截所有 /api/ 开头的请求
                .addPathPatterns("/api/**")
                // 放行登录、注册接口
                .excludePathPatterns(
                    "/api/login",
                    "/api/register"
                );
    }
}

拦截结构

一个项目中可以创建任意多个实现了 HandlerInterceptor 接口的拦截器类。 每个类负责一种独立的校验逻辑,比如登录鉴权、权限校验、操作日志、防重复提交等等。

实际项目里通常是 3 到 5 个,按职责拆分:

  • LoginInterceptor ------ 校验 Token,判断用户是否登录
  • PermissionInterceptor ------ 校验用户是否有权限访问某个接口
  • LogInterceptor ------ 记录每个请求的路径、参数和耗时
  • RateLimitInterceptor ------ 防止用户短时间内重复提交
    ......

然后在 WebMvcConfigaddInterceptors 方法里,按执行顺序依次注册,各自配置独立的拦截路径和放行路径。

一个项目通常只有一个 WebMvcConfig 配置类,一个 addInterceptors 方法。 在这个方法里,按执行顺序依次 registry.addInterceptor(...),每个拦截器都可以配置自己独立的 addPathPatternsexcludePathPatterns

java 复制代码
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 拦截器一:只对 /api/admin/** 路径做权限校验
        registry.addInterceptor(new AdminInterceptor())
                .addPathPatterns("/api/admin/**");

        // 拦截器二:对所有 /api/** 路径做登录校验
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/login", "/api/register");

        // 拦截器三:对所有 /api/** 路径记日志
        registry.addInterceptor(new LogInterceptor())
                .addPathPatterns("/api/**");
    }
}

请求拦截的完整过程(前置处理)

  1. 前端发 GET /api/user/info,请求头里带 token: admin_token_123
  2. 请求到达 Spring 的 DispatcherServlet
  3. DispatcherServlet 根据 URL 找到了 UserController.getUserInfo 方法。
  4. 在真正调用 Controller 之前 ,Spring 检查到有拦截器注册在 /api/** 路径上。
  5. Spring 自动调用 LoginInterceptor.preHandle(...)
  6. preHandle 里你用 request.getHeader("token") 拿到了 "admin_token_123"
  7. 校验通过,返回 true
  8. Spring 继续执行 UserController.getUserInfo(),返回数据给前端。

如果第 6 步拿到的 Token 是空的或者非法的,preHandle 返回 false,第 8 步就不会发生,前端直接收到 401 错误。

静态资源要不要放行?

在前后端分离架构项目里,大概率不需要。

原因很简单:前后端分离架构中,前端代码(Vue、React 或普通 HTML)是独立启动的,不放在 Spring Boot 的 static 目录下。后端只提供 JSON 接口,不提供 HTML 页面。

所以项目里根本就没有静态资源需要处理,自然不需要写 "/**/*.html" 这些放行规则。

什么时候需要放行静态资源?

只有一种情况:把前端打包后的文件放到了 src/main/resources/static/ 目录下,前端和后端是同一个项目部署的。这时候用户访问 http://localhost:8080/index.html 实际上是在请求 Spring Boot 里的静态文件,如果拦截器把 .html.css.js 也拦截了,页面就白屏打不开。

判断标准 :打开 src/main/resources/static/ 目录,看一眼里面有没有东西。空的就不需要放行,有文件就需要加上。

路径匹配规则详解

addPathPatternsexcludePathPatterns 中,路径匹配遵循 Ant 风格规则:

  • /books:仅精准匹配 /books 这一条路径。
  • /books/*:匹配 /books/ 下一层级的任意路径(如 /books/1),但不匹配 多层级路径(如 /books/1/detail)。
  • /books/**:匹配 /books/ 下的所有层级 路径(企业级项目中最常用,如 /api/** 代表拦截所有 API 接口)。

拦截器生命周期与执行流程

HandlerInterceptor 接口定义了三个核心方法,它们构成了一个完整的请求生命周期:

  1. preHandle (前置处理)
    • 执行时机 :Controller 方法执行之前
    • 核心作用 :决定是否放行请求。返回 true 继续执行,返回 false 直接中断请求链路。绝大多数业务逻辑(如鉴权、限流)均在此方法中实现。
  2. postHandle (后置处理)
    • 执行时机 :Controller 方法执行之后 ,但在视图渲染(或 JSON 序列化写入响应体)之前
    • 核心作用 :可对 ModelAndView 进行修改。在前后端分离(直接返回 JSON)的项目中,此方法极少使用
  3. afterCompletion (完成处理)
    • 执行时机 :整个请求处理完毕(包括响应数据已写入)之后。
    • 核心作用 :用于资源清理(如清除 ThreadLocal 中的用户信息,防止内存泄漏)或记录全局耗时日志。无论请求是否发生异常,只要 preHandle 返回了 true,此方法必定执行

拦截器链配置与执行顺序

当项目中配置了多个拦截器(如:先执行日志拦截器,再执行登录拦截器)时,会形成拦截器链 。其执行顺序严格遵循 "先进后出(栈结构)" 的原则。

假设有拦截器 A 和拦截器 B,配置顺序为先 A 后 B:

  1. preHandle 阶段(正序) :A 的 preHandle -> B 的 preHandle
  2. Controller 执行
  3. postHandle 阶段(逆序) :B 的 postHandle -> A 的 postHandle
  4. afterCompletion 阶段(逆序) :B 的 afterCompletion -> A 的 afterCompletion

阻断机制 : 如果在 B 的 preHandle 中返回了 false(拦截了请求):

  • Controller 方法不会执行。
  • A 和 B 的 postHandle不会执行
  • A 的 afterCompletion 依然会执行 (因为 A 的 preHandle 已经成功放行,Spring 框架必须保证 A 的资源清理逻辑得到执行),而 B 的 afterCompletion 不会执行。

注意事项

1. 拦截器注入 Bean 时空指针异常

  • 拦截器类上必须加 @Component 注解;
  • WebMvcConfig 配置类中,通过 @Autowired 注入拦截器实例,然后将这个注入的实例传给 registry.addInterceptor(),而不是直接 new

2. ThreadLocal 使用后必须清理

  • 必须在拦截器的 afterCompletion 方法中调用 ThreadLocal.remove() 进行强制清理。
  • afterCompletion 方法无论请求是否发生异常,只要 preHandle 返回了 true,它就一定会执行,是清理 ThreadLocal 最合适的位置。
相关推荐
团团崽_七分甜1 小时前
后端基础概念 - 前端开发者指南(进程、线程、事务)
后端
倔强的石头_1 小时前
从5万次到日均2000万次调用:大模型推理服务的性能工程实践V2.1
后端
环境栈笔记2 小时前
多账号浏览器选型检查清单:Profile、权限、Session 和任务日志怎么评估
前端·人工智能·后端·自动化
shepherd1112 小时前
一次把 Spring MVC 文件上传参数“查没了”的排查:multipart、Filter 与 request body 的连环坑
java·spring boot·后端
mONESY2 小时前
Node.js fs 文件系统模块 四种读写方案全解析
javascript·后端
Larcher2 小时前
从回调地狱到优雅异步:Node.js 文件操作进化史
javascript·后端·架构
Java编程爱好者2 小时前
深入浅出Agent: Harness最全调研与原理解析(持续更新中)
后端
日月云棠2 小时前
Spring 源码深度剖析:DispatcherServlet 的自动化注册机制
java·后端
码事漫谈3 小时前
让 Codex CLI 跑上 DeepSeek:用 LiteLLM 做 Responses↔Chat 协议翻译
后端