Spring Boot 4 旅游主题实战教程 阶段二:Web 开发基础

Spring Boot 4 旅游主题实战教程 阶段二:Web 开发基础

第04章 Web 开发基础:REST 接口全掌握

本章目标:掌握 GET/POST/PUT/DELETE 四类 HTTP 方法在 Spring MVC 中的写法,吃透 @PathVariable、@RequestParam、@RequestBody 三种参数接收方式,了解静态资源与 ResponseEntity。

上章回顾:第03章配置体系已就绪------参数可以外置了。现在回到接口本身:第01章只写了 GET,但真实的旅游系统必须支持"提交订单(POST)、修改行程(PUT)、取消订单(DELETE)"。

下一章预告 :本章查不到景点时只能返回 {"error": "..."},成功响应也是"裸奔"的 JSON。第05章《Web 进阶》引入统一响应结构与全局异常处理,让所有接口穿上统一的"外衣"。


4.1 RESTful 风格速览

REST 是一套用 URL 表达资源、用 HTTP 方法表达动作的设计风格:

操作 HTTP 方法 URL 示例 成功状态码
查询列表 GET /attractions 200
查询单个 GET /attractions/1 200
新增 POST /attractions 201
全量更新 PUT /attractions/1 200
删除 DELETE /attractions/1 204

要点:

  • URL 是名词(资源),不出现 get/add/delete 等动词;
  • 动作由 HTTP 方法表达;
  • 状态码语义化:200 OK、201 Created、204 No Content、404 Not Found。

4.2 三种参数接收方式对比

注解 数据来源 典型场景
@PathVariable URL 路径占位符 /attractions/1 定位唯一资源
@RequestParam 查询字符串 ?city=大理 过滤、排序、分页
@RequestBody 请求体 JSON POST/PUT 提交数据

4.3 创建第04章模块

4.3.1 根 pom 注册模块

xml 复制代码
    <modules>
        <module>chapter01-hello</module>
        <module>chapter02-bean</module>
        <module>chapter03-config</module>
        <module>chapter04-web-basic</module>
    </modules>

4.3.2 本章 pom.xml

创建 chapter04-web-basic/pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!--
  第04章:Web 开发基础 ------ REST 接口全掌握

  依赖与前三章相同。本章聚焦:
  GET/POST/PUT/DELETE 四类方法 + @PathVariable/@RequestParam/@RequestBody
  三种参数接收方式 + 静态资源访问。
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.lihaozhe</groupId>
        <artifactId>sb-travel</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>chapter04-web-basic</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

4.4 编写代码

4.4.1 实体:Attraction.java

路径:src/main/java/com/lihaozhe/chapter04/model/Attraction.java

java 复制代码
package com.lihaozhe.chapter04.model;

import java.time.LocalDateTime;

/**
 * 景点实体(第04章)
 *
 * <p>理论知识 ------ record + Jackson:
 * <ul>
 *   <li>Jackson 能直接序列化/反序列化 record:反序列化时调用它的"规范构造器",
 *       字段名一一对应 JSON 的 key;</li>
 *   <li>LocalDateTime 也能自动互转(默认 ISO-8601 格式:2026-08-25T12:00:00);</li>
 *   <li>本章新增 POST/PUT 需要"JSON → 对象"的反序列化能力,
 *       正好演示 record 作为请求体的用法。</li>
 * </ul>
 *
 * @param id          景点ID
 * @param name        景点名称
 * @param city        所在城市
 * @param price       门票价格(元)
 * @param createTime  创建时间
 */
public record Attraction(
        Long id,
        String name,
        String city,
        double price,
        LocalDateTime createTime) {
}

4.4.2 控制器:AttractionController.java(核心)

路径:src/main/java/com/lihaozhe/chapter04/controller/AttractionController.java

java 复制代码
package com.lihaozhe.chapter04.controller;

import com.lihaozhe.chapter04.model.Attraction;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * 景点管理接口(第04章:REST 全家桶)
 *
 * <p>用一个内存 Map 模拟数据库,完整演示增删改查四种 HTTP 方法。
 */
@RestController
@RequestMapping("/attractions")
public class AttractionController {

    /** ConcurrentHashMap:线程安全的内存"数据库"(多个请求并发读写) */
    private final Map<Long, Attraction> db = new ConcurrentHashMap<>();

    /** ID 生成器:AtomicLong 保证并发下自增不重复 */
    private final AtomicLong idGen = new AtomicLong(0);

    /** 初始化三条演示数据(构造器在容器启动时执行一次) */
    public AttractionController() {
        save(new Attraction(null, "长白山天池", "延边", 105.0, null));
        save(new Attraction(null, "洱海", "大理", 0, null));
        save(new Attraction(null, "黄鹤楼", "武汉", 70.0, null));
    }

    // ==================== 查询:GET ====================

    /**
     * 接口1:分页+筛选查询列表
     * GET /attractions?city=大理&minPrice=0&sort=price
     *
     * <p>@RequestParam 三要点:
     * <ul>
     *   <li>required=false:参数可省略(默认必须传,否则400);</li>
     *   <li>defaultValue="...":省略时用默认值(此时自动 required=false);</li>
     *   <li>参数名与 URL 的 ?key= 一致时注解可省略名字。</li>
     * </ul>
     */
    @GetMapping
    public List<Attraction> list(
            @RequestParam(required = false) String city,
            @RequestParam(defaultValue = "0") double minPrice,
            @RequestParam(defaultValue = "id") String sort) {
        var stream = db.values().stream();
        if (city != null) {
            stream = stream.filter(a -> a.city().equals(city));
        }
        stream = stream.filter(a -> a.price() >= minPrice);
        stream = switch (sort) {
            // Java 21+ switch 表达式:每个分支产生一个值,无需 break
            case "price" -> stream.sorted(java.util.Comparator.comparingDouble(Attraction::price));
            case "name" -> stream.sorted(java.util.Comparator.comparing(Attraction::name));
            default -> stream.sorted(java.util.Comparator.comparing(Attraction::id));
        };
        return stream.toList();
    }

    /**
     * 接口2:查询单个 + 自定义响应头演示
     * GET /attractions/1
     *
     * <p>@RequestHeader 可读取请求头------这里回显客户端的 User-Agent。
     */
    @GetMapping("/{id}")
    public Map<String, Object> detail(@PathVariable Long id,
                                      @RequestHeader(value = "User-Agent", defaultValue = "unknown") String ua) {
        Attraction attraction = db.get(id);
        if (attraction == null) {
            // 手动返回 404:第05章会用全局异常替代这种啰嗦写法
            return Map.of("error", "景点不存在", "id", id);
        }
        return Map.of("data", attraction, "yourUA", ua);
    }

    // ==================== 新增:POST ====================

    /**
     * 接口3:新增景点
     * POST /attractions,请求体是 JSON
     *
     * <p>@RequestBody:把请求体里的 JSON 反序列化成 record 对象。
     * Content-Type 必须是 application/json。
     *
     * <p>@ResponseStatus:方法正常返回时给客户端 201 Created(RESTful 约定:
     * 创建成功不用默认的 200,而是 201)。
     */
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Attraction add(@RequestBody Attraction input) {
        return save(input);
    }

    /** 内部创建逻辑:生成ID和时间戳后入库 */
    private Attraction save(Attraction input) {
        long id = idGen.incrementAndGet();
        var saved = new Attraction(id, input.name(), input.city(), input.price(), LocalDateTime.now());
        db.put(id, saved);
        return saved;
    }

    // ==================== 更新:PUT ====================

    /**
     * 接口4:全量更新
     * PUT /attractions/1,请求体 JSON 包含全部字段
     *
     * <p>PUT vs PATCH:PUT 约定为"整体替换",PATCH 为"局部修改";
     * 本章用 PUT 覆盖除 id/createTime 外的全部字段。
     *
     * <p>ResponseEntity<T>:可以精确控制状态码+响应头+响应体的"全家桶"返回类型,
     * 比 @ResponseStatus 更灵活(能按业务结果动态决定状态码)。
     */
    @PutMapping("/{id}")
    public ResponseEntity<Attraction> update(@PathVariable Long id, @RequestBody Attraction input) {
        Attraction old = db.get(id);
        if (old == null) {
            // 不存在则返回 404,响应体为空
            return ResponseEntity.notFound().build();
        }
        var updated = new Attraction(id, input.name(), input.city(), input.price(), old.createTime());
        db.put(id, updated);
        // 200 OK + 更新后的实体
        return ResponseEntity.ok(updated);
    }

    // ==================== 删除:DELETE ====================

    /**
     * 接口5:删除景点
     * DELETE /attractions/3
     *
     * <p>RESTful 约定删除成功返回 204 No Content(成功但没有响应体)。
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (db.remove(id) == null) {
            return ResponseEntity.notFound().build();
        }
        // noContent() = 204 状态码 + 无响应体
        return ResponseEntity.noContent().build();
    }
}

4.4.3 静态页面:index.html

路径:src/main/resources/static/index.html

html 复制代码
<!DOCTYPE html>
<!-- 第04章:静态资源演示页面 -->
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>第04章 静态资源测试</title>
</head>
<body>
<h1>走遍中国 · 静态资源页</h1>
<p>本文件位于 src/main/resources/static/index.html</p>
<p>Spring Boot 自动把 static/ 目录作为静态资源根目录,无需任何配置。</p>
<a href="/attractions">点击查看景点 JSON 接口</a>
</body>
</html>

4.4.4 配置文件:application.yml

路径:src/main/resources/application.yml

yaml 复制代码
# =====================================================================
# 第04章 配置文件
# =====================================================================
server:
  port: 8104

spring:
  application:
    name: chapter04-web-basic
  web:
    resources:
      # 静态资源缓存时间(秒)------演示静态资源也是可配置的
      cache:
        period: 3600

4.4.5 启动类:TravelApplication.java

路径:src/main/java/com/lihaozhe/chapter04/TravelApplication.java

java 复制代码
package com.lihaozhe.chapter04;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第04章启动类:Web 开发基础
 *
 * <p>理论知识 ------ RESTful 风格约定(用 URL 表达资源,用 HTTP 方法表达动作):
 * <pre>
 *   GET    /attractions      查询列表
 *   GET    /attractions/{id} 查询单个
 *   POST   /attractions      新增
 *   PUT    /attractions/{id} 全量更新
 *   DELETE /attractions/{id} 删除
 * </pre>
 * 同一个 /attractions 路径,靠 HTTP 方法区分操作------URL 里不出现 get/add/delete 等动词。
 */
@SpringBootApplication
public class TravelApplication {

    public static void main(String[] args) {
        SpringApplication.run(TravelApplication.class, args);
    }
}

4.5 运行与验证

bash 复制代码
mvn -pl chapter04-web-basic -am clean package -DskipTests
cd chapter04-web-basic
mvn spring-boot:run

以下均为真实运行结果。

查询列表

bash 复制代码
curl http://localhost:8104/attractions
json 复制代码
[{"id":1,"name":"长白山天池","city":"延边","price":105.0,"createTime":"2026-08-25T21:20:39.784638"},{"id":2,"name":"洱海","city":"大理","price":0.0,"createTime":"2026-08-25T21:20:39.784638"},{"id":3,"name":"黄鹤楼","city":"武汉","price":70.0,"createTime":"2026-08-25T21:20:39.784638"}]

条件筛选 + 排序

bash 复制代码
curl "http://localhost:8104/attractions?sort=price"
json 复制代码
[{"id":2,"name":"洱海",...},{"id":3,"name":"黄鹤楼",...},{"id":1,"name":"长白山天池",...}]

按价格从低到高排序生效。

POST 新增 (注意 Windows 的 cmd/PowerShell 直接内联中文 JSON 可能有编码问题,建议把 JSON 存成 UTF-8 文件后用 --data-binary @文件 发送):

bash 复制代码
curl -i -X POST http://localhost:8104/attractions \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-binary @post.json

post.json 内容(UTF-8 编码):

json 复制代码
{"name":"喀纳斯","city":"阿勒泰","price":160.0}

返回第一行:

text 复制代码
HTTP/1.1 201

PUT 更新

bash 复制代码
curl -X PUT http://localhost:8104/attractions/3 \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-binary @put.json

put.json 内容:

json 复制代码
{"name":"黄鹤楼公园","city":"武汉","price":75.0}

返回:

json 复制代码
{"id":3,"name":"黄鹤楼公园","city":"武汉","price":75.0,"createTime":"2026-08-25T21:20:39.784638"}

createTime 保持原值------更新没有覆盖它。

DELETE 删除

bash 复制代码
curl -i -X DELETE http://localhost:8104/attractions/2
text 复制代码
HTTP/1.1 204

删除不存在的:

bash 复制代码
curl -i -X DELETE http://localhost:8104/attractions/999
text 复制代码
HTTP/1.1 404

静态资源:浏览器打开 http://localhost:8104/index.html ,能看到演示页面(状态码 200)。

踩坑实录 :如果 curl 发送中文 JSON 时服务端报 Invalid UTF-8 start byte,说明终端发送的字节不是 UTF-8。解决办法就是上面用的:JSON 写入 UTF-8 文件,--data-binary @file 发送。这个坑 Windows 用户几乎必遇一次。

4.6 本章小结

你学会了 关键点
RESTful 设计 名词 URL + 动词 HTTP 方法 + 语义化状态码
@PathVariable 取路径占位符,定位资源
@RequestParam 取查询参数,required/defaultValue 控制
@RequestBody JSON 反序列化为 record
ResponseEntity 动态控制状态码与响应体
@ResponseStatus 固定成功状态码(如 201)

4.7 动手练习

  1. 增加 GET /attractions/search?keyword=x 接口:按名称模糊搜索。
  2. 增加 PATCH 局部更新接口:只改价格不改其它字段(提示:请求体只带 price 字段,用一个只含 price 的 record 接收)。
  3. 给列表接口加分页参数 page/size,返回 {total, page, size, list} 结构。

下一章预告{"error":"景点不存在"} 这种错误结构是临时凑合的------成功和失败长得不一样,前端没法统一处理。第05章《Web 进阶》打造统一响应体 Result + 全局异常处理器 + 拦截器,让接口工程化。

4.8 本章使用的 Java 25 新特性

特性 说明 本章应用
record (Java 16+) 不可变数据载体,自动生成全参构造器、访问器、equals/hashCode/toString Attraction 类用 record 定义景点实体,支持 Jackson 自动序列化/反序列化(Attraction.java:128)
switch 表达式 (Java 21+) 每个分支产生一个值,无需 break,代码更紧凑 list() 方法中按 sort 参数动态选择排序方式(AttractionController.java:69-74)
var 局部变量类型推断 (Java 10+) 编译器根据右值自动推断变量类型 多处使用:stream 对象(line 64)、新建的 Attraction(line 116)、更新对象(line 140)
Stream API (Java 8+) 声明式集合处理,支持 filter/map/reduce 等操作 list() 中筛选、排序景点数据(AttractionController.java:64-75)
List.of() / Map.of() (Java 9+) 不可变集合工厂方法,代码更简洁 guides()detail() 中直接创建不可变集合(AttractionController.java:79、90、92)
LocalDateTime (Java 8+) 不可变的日期时间类型,支持 ISO-8601 格式序列化 save() 中记录景点创建时间(AttractionController.java:116)

为什么用现代语法? record 让 JSON 序列化零配置;switch 表达式替代冗长的 if-else 或传统 switch,代码更安全(无需 break);Stream API 替代手动 for 循环,逻辑更清晰;var 减少样板代码;LocalDateTime 提供标准化的时间表示,避免时区陷阱。

第05章 Web 进阶:统一响应、全局异常与拦截器

本章目标:打造 Result 统一响应体,用 @RestControllerAdvice 集中处理所有异常,用拦截器实现接口耗时统计,并配置好 CORS 跨域。

上章回顾 :第04章查不到景点时返回 {"error": "..."}------成功和失败结构不一致,前端要写两套解析逻辑;每个接口各写 try-catch 更是灾难。

下一章预告:响应结构有了,但"新增目的地时 name 传了空字符串"怎么办?第06章《参数校验》用注解一行搞定校验。


5.1 统一响应体:Result<T>

目标结构:

json 复制代码
{ "code": 0,      "message": "success", "data": {...} }   // 成功
{ "code": 2001,   "message": "目的地不存在...", "data": null } // 业务失败
{ "code": 5000,   "message": "系统繁忙,请稍后再试", "data": null } // 系统异常

设计要点:

  • code = 0 表示成功,非 0 一律失败;错误码分段:1xxx 参数问题、2xxx 业务问题、5xxx 系统问题;
  • 泛型 T 保证 data 的编译期类型安全;
  • 私有构造 + 静态工厂(ok/fail):外部只能按语义创建实例。

5.2 全局异常处理:@RestControllerAdvice

工作原理一句话:它是给所有 Controller 加的"统一 catch"。Controller 方法抛出的任何异常都会被路由到 @ExceptionHandler 方法,按参数类型就近匹配。

复制代码
Controller 抛出 BusinessException
        ↓
DispatcherServlet 捕获
        ↓
@RestControllerAdvice 按 @ExceptionHandler(BusinessException.class) 匹配
        ↓
handleBusiness() 返回 Result → 写入响应体

5.3 创建第05章模块

5.3.1 根 pom 注册模块

xml 复制代码
    <modules>
        <module>chapter01-hello</module>
        <module>chapter02-bean</module>
        <module>chapter03-config</module>
        <module>chapter04-web-basic</module>
        <module>chapter05-web-advanced</module>
    </modules>

5.3.2 本章 pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!--
  第05章:Web 进阶 ------ 统一响应、全局异常、拦截器、CORS
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.lihaozhe</groupId>
        <artifactId>sb-travel</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>chapter05-web-advanced</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <!-- 引入 validation starter:全局异常处理器要处理 MethodArgumentNotValidException,
             该异常由参数校验框架抛出,第06章将系统学习,这里先让异常处理器"认识"它 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

5.4 编写代码

5.4.1 Result.java ------ 统一响应体

路径:src/main/java/com/lihaozhe/chapter05/common/Result.java

java 复制代码
package com.lihaozhe.chapter05.common;

/**
 * 统一响应体(第05章的核心类)
 *
 * <p>理论知识 ------ 为什么要统一响应结构?
 * 前端拿到任何接口的返回,都按同一套规则解析:
 * <pre>
 *   { "code": 0, "message": "success", "data": {...} }
 * </pre>
 * code=0 表示成功,非 0 表示各类失败------前端只需判断一个字段。
 *
 * <p>设计说明:
 * <ul>
 *   <li>泛型 T:data 的类型随接口而变(列表/对象/布尔),编译期类型安全;</li>
 *   <li>私有构造器 + 静态工厂方法:外部不能随意 new,只能通过
 *       ok()/fail() 创建语义明确的实例------这是"受控创建"的经典手法;</li>
 *   <li>用 record 而不是 Lombok:零依赖、不可变。</li>
 * </ul>
 *
 * @param code    业务状态码:0=成功;1xxx=通用错误;2xxx=业务错误
 * @param message 提示信息(可直接展示给用户)
 * @param data    业务数据,失败时为 null
 * @param <T>     data 的实际类型
 */
public record Result<T>(int code, String message, T data) {

    /** 成功状态码常量 */
    public static final int SUCCESS = 0;
    /** 通用失败码 */
    public static final int ERROR = 1000;

    /** 静态工厂:成功 + 携带数据 */
    public static <T> Result<T> ok(T data) {
        return new Result<>(SUCCESS, "success", data);
    }

    /** 静态工厂:成功 + 无数据 */
    public static Result<Void> ok() {
        return new Result<>(SUCCESS, "success", null);
    }

    /** 静态工厂:失败 + 自定义码和消息 */
    public static <T> Result<T> fail(int code, String message) {
        return new Result<>(code, message, null);
    }

    /** 静态工厂:失败 + 默认码 */
    public static <T> Result<T> fail(String message) {
        return fail(ERROR, message);
    }
}

注意:record 的构造器默认就是"全参构造",没有 public 修饰的其它构造器即可达到受控效果。record 天生满足"字段 final + 构造唯一",配合静态工厂非常干净。

5.4.2 BusinessException.java ------ 业务异常

路径:src/main/java/com/lihaozhe/chapter05/common/BusinessException.java

java 复制代码
package com.lihaozhe.chapter05.common;

/**
 * 业务异常(第05章)
 *
 * <p>理论知识 ------ 为什么要自定义异常?
 * <ul>
 *   <li>Java 内置异常表达不了业务语义:NotFoundException?BusinessRuleException?
 *       都需要自己定义;</li>
 *   <li>业务代码里 throw new BusinessException(...) 即可"中断"流程,
 *       处理交给全局异常处理器------业务方法从此不再写 try-catch;</li>
 *   <li>继承 RuntimeException(非受检异常):不强迫调用方到处写 throws,
 *       这是 Spring 生态的惯例。</li>
 * </ul>
 */
public class BusinessException extends RuntimeException {

    /** 业务错误码,会透传到 Result.code */
    private final int code;

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

    /** 常用便捷构造:默认码 2000 */
    public BusinessException(String message) {
        this(2000, message);
    }

    public int getCode() {
        return code;
    }
}

5.4.3 GlobalExceptionHandler.java ------ 全局异常处理器

路径:src/main/java/com/lihaozhe/chapter05/common/GlobalExceptionHandler.java

java 复制代码
package com.lihaozhe.chapter05.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

/**
 * 全局异常处理器(第05章核心组件)
 *
 * <p>理论知识 ------ @RestControllerAdvice 的工作原理:
 * <ul>
 *   <li>它是"所有 @RestController 的 AOP 环绕增强":任何 Controller 方法抛出的异常,
 *       都会先被路由到这里,按 @ExceptionHandler 的参数类型匹配最具体的处理方法;</li>
 *   <li>Controller 里从此不写 try-catch------异常一路向上抛,由这里统一兜底;</li>
 *   <li>@RestControllerAdvice = @ControllerAdvice + @ResponseBody(处理结果直接写响应体)。</li>
 * </ul>
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * 处理业务异常:如"景点不存在""库存不足"
     * 返回 HTTP 200 + 业务错误码(前后端约定:看 code 而不是 HTTP 状态码判断业务成败)
     */
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusiness(BusinessException e) {
        // warn 级别:业务异常是预期内的,不需要 error 级别报警
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    /**
     * 处理 JSON 解析失败:前端发的 JSON 格式错误/编码错误
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public Result<Void> handleNotReadable(HttpMessageNotReadableException e) {
        log.warn("JSON解析失败: {}", e.getMessage());
        return Result.fail(1001, "请求体不是合法的JSON");
    }

    /**
     * 处理参数类型不匹配:如 /attractions/abc 中 abc 无法转成 Long
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
        log.warn("参数类型错误: name={}, value={}", e.getName(), e.getValue());
        return Result.fail(1002, "参数[" + e.getName() + "]类型不正确");
    }

    /**
     * 处理参数校验失败(@Valid 校验不通过时抛出,第06章主角)
     * 把所有字段的错误信息拼接成一条可读消息
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidation(MethodArgumentNotValidException e) {
        String detail = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .reduce((a, b) -> a + ";" + b)
                .orElse("参数校验失败");
        log.warn("参数校验失败: {}", detail);
        return Result.fail(1003, detail);
    }

    /**
     * 处理静态资源404:访问了不存在的路径
     */
    @ExceptionHandler(NoResourceFoundException.class)
    public Result<Void> handleNoResource(NoResourceFoundException e) {
        return Result.fail(1004, "请求的资源不存在: " + e.getResourcePath());
    }

    /**
     * 兜底处理器:上面都没接住的异常最终落到这里
     * Exception 类型匹配所有异常,但 Spring 总是优先匹配更具体的类型
     */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        // error 级别 + 完整堆栈:未知异常必须记录现场以便排查
        log.error("系统异常", e);
        return Result.fail(5000, "系统繁忙,请稍后再试");
    }
}

5.4.4 TimingInterceptor.java ------ 耗时统计拦截器

路径:src/main/java/com/lihaozhe/chapter05/common/TimingInterceptor.java

java 复制代码
package com.lihaozhe.chapter05.common;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * 接口耗时统计拦截器(第05章)
 *
 * <p>理论知识 ------ HandlerInterceptor 的三个时机:
 * <pre>
 *   preHandle   → Controller 方法执行【前】(返回 false 可直接拦下请求)
 *        ↓
 *   postHandle  → Controller 方法执行后、视图渲染前(前后端分离场景很少用)
 *        ↓
 *   afterCompletion → 请求完全结束后(无论成功失败都会执行,适合清理与统计)
 * </pre>
 *
 * <p>拦截器 vs 过滤器(Filter):
 * <ul>
 *   <li>Filter 是 Servlet 规范,在 DispatcherServlet 之前执行,能拦截一切请求;</li>
 *   <li>Interceptor 是 Spring MVC 机制,只拦截进 Controller 的请求,
 *       且能注入 Spring Bean、感知 handler 信息------业务相关横切逻辑首选。</li>
 * </ul>
 */
@Component
public class TimingInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(TimingInterceptor.class);

    /** ThreadLocal 存开始时间:每个 HTTP 请求由一个线程处理,线程间互不干扰 */
    private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 记录请求进入时间,放行(true = 继续执行后续流程)
        START_TIME.set(System.currentTimeMillis());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        long cost = System.currentTimeMillis() - START_TIME.get();
        // 记得清理 ThreadLocal!Tomcat 线程会被复用,残留数据会"串"到下一个请求
        START_TIME.remove();
        log.info("[耗时统计] {} {} → {}ms (HTTP {})",
                request.getMethod(), request.getRequestURI(), cost, response.getStatus());
    }
}

5.4.5 WebConfig.java ------ 注册拦截器 + CORS

路径:src/main/java/com/lihaozhe/chapter05/config/WebConfig.java

java 复制代码
package com.lihaozhe.chapter05.config;

import com.lihaozhe.chapter05.common.TimingInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Web MVC 配置类(第05章:注册拦截器 + 配置 CORS)
 *
 * <p>理论知识 ------ WebMvcConfigurer:
 * Spring Boot 留给开发者的"MVC 定制入口",实现它的回调方法即可
 * 追加配置,而不是覆盖 Boot 的默认配置(对比旧式继承 WebMvcConfigurationSupport)。
 *
 * <p>理论知识 ------ CORS(跨域资源共享):
 * 浏览器的同源策略禁止页面 A(http://a.com)的 JS 请求 B 域名(http://b.com),
 * 除非 B 在响应头里明确"授权"。前后端分离时前端 localhost:5173、后端 localhost:8105
 * 就是跨域,后端必须配 CORS,否则浏览器拦截响应。
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final TimingInterceptor timingInterceptor;

    public WebConfig(TimingInterceptor timingInterceptor) {
        this.timingInterceptor = timingInterceptor;
    }

    /** 注册拦截器:addPathPatterns 拦截哪些路径,excludePathPatterns 排除哪些 */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timingInterceptor)
                .addPathPatterns("/**")           // 拦截所有接口
                .excludePathPatterns("/error");   // 排除错误页
    }

    /** 全局 CORS 配置:允许任何来源访问本服务的任何接口 */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                // allowedOrigins("*") 与 allowCredentials(true) 不能同时用,
                // 新版本请用 allowedOriginPatterns
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .maxAge(3600);   // 预检请求缓存1小时,减少 OPTIONS 请求次数
    }
}

5.4.6 Destination.java 与 DestinationService.java

路径:src/main/java/com/lihaozhe/chapter05/model/Destination.java

java 复制代码
package com.lihaozhe.chapter05.model;

/**
 * 目的地实体(第05章)
 *
 * @param id   ID
 * @param name 名称
 * @param city 城市
 */
public record Destination(Long id, String name, String city) {
}

路径:src/main/java/com/lihaozhe/chapter05/service/DestinationService.java

java 复制代码
package com.lihaozhe.chapter05.service;

import com.lihaozhe.chapter05.common.BusinessException;
import com.lihaozhe.chapter05.model.Destination;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * 目的地服务(第05章)
 *
 * <p>注意:业务方法里没有任何 try-catch!
 * 找不到数据就抛 BusinessException,由全局异常处理器统一转换成 Result。
 */
@Service
public class DestinationService {

    private final Map<Long, Destination> db = new ConcurrentHashMap<>();
    private final AtomicLong idGen = new AtomicLong(0);

    public DestinationService() {
        save(new Destination(null, "喀什古城", "喀什"));
        save(new Destination(null, "稻城亚丁", "甘孜"));
    }

    public Destination save(Destination input) {
        long id = idGen.incrementAndGet();
        var saved = new Destination(id, input.name(), input.city());
        db.put(id, saved);
        return saved;
    }

    /** 查全部 */
    public List<Destination> findAll() {
        return List.copyOf(db.values());
    }

    /**
     * 按ID查------找不到直接抛业务异常(对比第04章返回 null/Map 的写法)
     */
    public Destination findById(Long id) {
        Destination d = db.get(id);
        if (d == null) {
            throw new BusinessException(2001, "目的地不存在: id=" + id);
        }
        return d;
    }
}

5.4.7 DestinationController.java

路径:src/main/java/com/lihaozhe/chapter05/controller/DestinationController.java

java 复制代码
package com.lihaozhe.chapter05.controller;

import com.lihaozhe.chapter05.common.Result;
import com.lihaozhe.chapter05.model.Destination;
import com.lihaozhe.chapter05.service.DestinationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 目的地接口(第05章:统一响应版)
 *
 * <p>对比第04章:所有方法都返回 Result<T>,成功失败结构完全一致。
 */
@RestController
@RequestMapping("/destinations")
public class DestinationController {

    private final DestinationService service;

    public DestinationController(DestinationService service) {
        this.service = service;
    }

    /**
     * 成功场景:GET /destinations
     * 返回 {"code":0,"message":"success","data":[...]}
     */
    @GetMapping
    public Result<java.util.List<Destination>> list() {
        return Result.ok(service.findAll());
    }

    /**
     * 业务失败场景:GET /destinations/999
     * Service 抛 BusinessException → 全局处理器接住 → 返回 {"code":2001,"message":"目的地不存在..."}
     * 注意 Controller 里一行异常处理代码都没有!
     */
    @GetMapping("/{id}")
    public Result<Destination> detail(@PathVariable Long id) {
        return Result.ok(service.findById(id));
    }

    /**
     * 主动抛异常演示:GET /destinations/demo/crash
     * 抛出非业务异常 → 落到兜底处理器 → {"code":5000,"message":"系统繁忙..."}
     */
    @GetMapping("/demo/crash")
    public Result<Void> crash() {
        throw new IllegalStateException("模拟空指针前的非法状态");
    }
}

5.4.8 启动类与配置文件

启动类 TravelApplication.java 与前几章相同(见本章代码包),配置文件 application.yml

yaml 复制代码
# =====================================================================
# 第05章 配置文件
# =====================================================================
server:
  port: 8105

spring:
  application:
    name: chapter05-web-advanced

5.5 运行与验证

bash 复制代码
mvn -pl chapter05-web-advanced -am clean package -DskipTests
cd chapter05-web-advanced
mvn spring-boot:run

以下均为真实运行结果。

成功场景

bash 复制代码
curl http://localhost:8105/destinations
json 复制代码
{"code":0,"message":"success","data":[{"id":1,"name":"喀什古城","city":"喀什"},{"id":2,"name":"稻城亚丁","city":"甘孜"}]}

业务异常(id=999 不存在)

bash 复制代码
curl http://localhost:8105/destinations/999
json 复制代码
{"code":2001,"message":"目的地不存在: id=999","data":null}

未知异常兜底

bash 复制代码
curl http://localhost:8105/destinations/demo/crash
json 复制代码
{"code":5000,"message":"系统繁忙,请稍后再试","data":null}

参数类型不匹配

bash 复制代码
curl http://localhost:8105/destinations/abc
json 复制代码
{"code":1002,"message":"参数[id]类型不正确","data":null}

CORS 生效验证(模拟前端预检请求)

bash 复制代码
curl -i -X OPTIONS http://localhost:8105/destinations \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: GET"

响应头(节选):

text 复制代码
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS

拦截器耗时日志(控制台输出,真实记录):

text 复制代码
[耗时统计] GET /destinations/999 → 8ms (HTTP 200)
[耗时统计] OPTIONS /destinations → 0ms (HTTP 200)

5.6 本章小结

你学会了 关键点
Result<T> 泛型 + record + 静态工厂
全局异常 异常类型就近匹配;业务异常 warn、未知异常 error+堆栈
BusinessException 抛出去比返回 null 好------调用方无法"忘记"失败
拦截器 preHandle/afterCompletion + ThreadLocal 清理
CORS 后端授权跨域;allowedOriginPatterns 用法

5.7 动手练习

  1. 给 Result 增加 timestamp 字段(long 类型毫秒时间戳),观察所有响应的变化。
  2. 新增一个错误码 2002"城市暂未开通服务",并在 Service 中某条件下抛出,验证前端拿到的 code。
  3. 再写一个登录检查拦截器:请求头缺少 X-Token 时直接 response.setStatus(401) 并返回 false 放弃执行(提示:在 preHandle 里操作 HttpServletResponse 写 JSON)。

下一章预告 :现在校验参数还得手写 if (name == null || name.isBlank())。第06章《参数校验 Validation》用 @NotBlank @Min @Email 等注解声明式校验,并自定义"手机号"注解。

5.8 本章使用的 Java 25 新特性

特性 说明 本章应用
record (Java 16+) 不可变数据载体,自动生成全参构造器、访问器、equals/hashCode/toString Result<T>Destination 用 record 定义(Result.java:136、Destination.java:435),保证响应体和实体的不可变性
Stream API + reduce (Java 8+) 声明式集合处理,reduce 将流简化为单个值 GlobalExceptionHandler.handleValidation() 中拼接多个字段错误为一条消息(GlobalExceptionHandler.java:64-67)
泛型 (Java 5+) 编译期类型安全的参数化类型 Result<T> 中的泛型 T 让 data 字段类型随接口变化(Result.java:26)

为什么用现代语法? record 让 Result 和 Destination 不可变且零样板代码;Stream API 的 reduce 操作优雅地聚合多个校验错误;泛型保证 Result 中 data 的类型安全,避免了强制类型转换。

第06章 参数校验 Validation:告别手写 if

本章目标:掌握 Jakarta Validation 常用注解,理解 @Valid 与 @Validated 的分工,学会嵌套对象校验与自定义校验注解。

上章回顾:第05章的全局异常处理器已经"认识"了 MethodArgumentNotValidException------本章让它真正登场。

下一章预告:接口的"面子工程"全部完工。从第07章开始进入数据持久化:连接 MySQL,用 JdbcTemplate 把景点存进真实数据库。


6.1 手写校验之痛

没有校验框架时的代码:

java 复制代码
if (request.name() == null || request.name().isBlank()) {
    return Result.fail("姓名不能为空");
}
if (request.peopleCount() == null || request.peopleCount() < 1 || request.peopleCount() > 10) {
    return Result.fail("人数必须在1到10之间");
}
// ......十几个字段要写几十行

问题:啰嗦、易漏、校验规则与业务逻辑搅在一起、无法复用。

声明式校验的答案------把规则写在字段上:

java 复制代码
@NotBlank(message = "姓名不能为空")
String name;

@Min(1) @Max(10)
Integer peopleCount;

6.2 核心机制

  • 规范 vs 实现 :Jakarta Bean Validation 是规范(jakarta.validation.* 注解),Hibernate Validator 是默认实现;
  • 触发开关 :Controller 参数前加 @Valid(或 @Validated)才生效;
  • 失败表现 :@RequestBody 校验失败抛 MethodArgumentNotValidException;单参数(@RequestParam 等)校验失败抛 ConstraintViolationException------两者都要在全局异常处理器中接住。

6.3 创建第06章模块

6.3.1 根 pom 注册模块

xml 复制代码
        <module>chapter06-validation</module>

6.3.2 本章 pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!--
  第06章:参数校验 Validation
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.lihaozhe</groupId>
        <artifactId>sb-travel</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>chapter06-validation</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <!-- 校验启动器:Jakarta Validation 规范 + Hibernate Validator 实现 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

6.4 编写代码

6.4.1 自定义注解:PhoneNo.java

路径:src/main/java/com/lihaozhe/chapter06/validation/PhoneNo.java

java 复制代码
package com.lihaozhe.chapter06.validation;

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.regex.Pattern;

/**
 * 自定义校验注解:手机号(第06章压轴知识点)
 *
 * <p>理论知识 ------ 一个自定义校验注解 = "注解定义" + "校验器"两部分:
 * <ul>
 *   <li>@Constraint(validatedBy = PhoneNo.Validator.class):把注解和校验逻辑绑定;</li>
 *   <li>三要素 message() / groups() / payload() 是规范要求的固定写法,缺一不可;</li>
 *   <li>校验器实现 ConstraintValidator<注解, 被校验字段类型>:
 *       泛型第二个参数决定它能标在什么类型上(这里 String)。</li>
 * </ul>
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)     // 运行时可读------框架靠反射读取它
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Repeatable(PhoneNo.List.class)         // 允许同一字段重复标注(不同分组用不同提示)
@Constraint(validatedBy = PhoneNo.Validator.class)
public @interface PhoneNo {

    /** 校验失败时的提示消息,使用时可以覆盖:@PhoneNo(message = "...") */
    String message() default "手机号格式不正确";

    /** 分组校验支持(固定写法) */
    Class<?>[] groups() default {};

    /** 元数据载荷(固定写法,一般不动) */
    Class<? extends Payload>[] payload() default {};

    /**
     * 校验器:真正的判断逻辑在这里
     */
    class Validator implements ConstraintValidator<PhoneNo, String> {

        /** 中国大陆手机号正则:1开头 + 第二位3-9 + 共11位数字 */
        private static final Pattern PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

        /**
         * @param value   被校验的字段值
         * @param context 校验上下文(可用于自定义错误消息)
         * @return true=通过 false=不通过
         */
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            // 约定:null 交给 @NotNull/@NotBlank 去管,本注解只管"格式"
            if (value == null || value.isBlank()) {
                return true;
            }
            return PATTERN.matcher(value).matches();
        }
    }

    /** @Repeatable 需要的容器注解(固定写法) */
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.FIELD, ElementType.PARAMETER})
    @interface List {
        PhoneNo[] value();
    }
}

6.4.2 请求体:BookingRequest.java ------ 注解全家福

路径:src/main/java/com/lihaozhe/chapter06/model/BookingRequest.java

java 复制代码
package com.lihaozhe.chapter06.model;

import com.lihaozhe.chapter06.validation.PhoneNo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import java.util.List;

/**
 * 跟团报名请求体(第06章:校验注解全家福)
 *
 * <p>理论知识 ------ record + 校验注解:
 * 注解直接标在 record 组件上,Jakarta Validation 会把它们应用到对应字段。
 */
public record BookingRequest(

        /** 游客姓名:非空且2~20个字符。message 属性自定义中文提示 */
        @NotBlank(message = "姓名不能为空")
        @Size(min = 2, max = 20, message = "姓名长度须在{min}到{max}个字符之间")
        String name,

        /** 联系电话:用我们自定义的 @PhoneNo 注解校验格式 */
        @NotBlank(message = "联系电话不能为空")
        @PhoneNo
        String phone,

        /** 同行人数:1~10 人 */
        @NotNull(message = "人数不能为空")
        @Min(value = 1, message = "至少要有1位游客")
        @Max(value = 10, message = "单次最多报名10人")
        Integer peopleCount,

        /** 预算金额:必须大于0 */
        @NotNull(message = "预算不能为空")
        @DecimalMin(value = "0.01", message = "预算必须大于0")
        Double budget,

        /** 身份证号:正则演示(简化版18位) */
        @Pattern(regexp = "^\\d{17}[\\dXx]$", message = "身份证号格式不正确")
        String idCard,

        /**
         * 嵌套对象校验的关键:加 @Valid 才会"递归"进去校验!
         * 不加的话 EmergencyContact 里面的注解全部不生效------高频坑点。
         */
        @NotNull(message = "紧急联系人不能为空")
        @Valid
        EmergencyContact emergencyContact,

        /** 景点偏好列表:@Size 校验列表长度 */
        @Size(max = 5, message = "最多选择5个偏好景点")
        List<String> preferredAttractions) {

    /**
     * 紧急联系人(嵌套校验对象)
     */
    public record EmergencyContact(
            @NotBlank(message = "紧急联系人姓名不能为空")
            String contactName,
            @NotBlank(message = "紧急联系人电话不能为空")
            @PhoneNo(message = "紧急联系人电话格式不正确")
            String contactPhone) {
    }
}

6.4.3 控制器:BookingController.java

路径:src/main/java/com/lihaozhe/chapter06/controller/BookingController.java

java 复制代码
package com.lihaozhe.chapter06.controller;

import com.lihaozhe.chapter06.common.Result;
import com.lihaozhe.chapter06.model.BookingRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 报名接口(第06章)
 *
 * <p>理论知识 ------ @Valid 与 @Validated 的区别:
 * <ul>
 *   <li>@Valid:Jakarta 规范注解,标在参数/字段上触发校验,支持嵌套校验;</li>
 *   <li>@Validated:Spring 增强,多一个"分组"能力,且能标注在类上
 *       让方法参数(@RequestParam/@PathVariable)也被校验。</li>
 * </ul>
 */
@RestController
@RequestMapping("/bookings")
@Validated   // 类上标注后:下面的 @RequestParam 才会被校验
public class BookingController {

    /**
     * 接口1:提交报名(对象校验)
     * POST /bookings
     *
     * <p>参数前的 @Valid 是"开关"------没有它,BookingRequest 里的所有注解都不生效。
     */
    @PostMapping
    public Result<String> book(@Valid @RequestBody BookingRequest request) {
        // 走到这里说明全部校验已通过,安心写业务即可(无需任何 if 判断)
        return Result.ok("报名成功:" + request.name() + ",同行 " + request.peopleCount() + " 人");
    }

    /**
     * 接口2:查询名额(单参数校验演示)
     * GET /bookings/quota?attractionId=5&days=3
     *
     * <p>注意:@RequestParam 的校验失败抛的是 ConstraintViolationException,
     * 与 @RequestBody 的 MethodArgumentNotValidException 不同!
     */
    @GetMapping("/quota")
    public Result<Integer> quota(
            @RequestParam
            @Min(value = 1, message = "景点ID必须为正数") Long attractionId,
            @RequestParam(defaultValue = "1")
            @Min(value = 1, message = "天数最少1天")
            @Max(value = 30, message = "天数最多30天") int days) {
        // 模拟按景点+天数计算剩余名额
        int quota = 100 - days * 10;
        return Result.ok(quota);
    }
}

6.4.4 公共三件套(Result / BusinessException / GlobalExceptionHandler)

Result 与 BusinessException 和第05章相同(见本章代码包)。全局异常处理器是精简版,重点多了 ConstraintViolationException 处理。

路径:src/main/java/com/lihaozhe/chapter06/common/GlobalExceptionHandler.java

java 复制代码
package com.lihaozhe.chapter06.common;

import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

/**
 * 全局异常处理器(第06章精简版:重点展示校验异常的处理)
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * @Valid 校验失败时抛出 MethodArgumentNotValidException。
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidation(MethodArgumentNotValidException e) {
        // 拼接所有字段错误:"名称不能为空;价格必须在0到10000之间"
        String detail = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .reduce((a, b) -> a + ";" + b)
                .orElse("参数校验失败");
        return Result.fail(1003, detail);
    }

    /** 路径/查询参数类型不匹配(如 id=abc) */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
        return Result.fail(1002, "参数[" + e.getName() + "]类型不正确");
    }

    /**
     * 单参数(@RequestParam/@PathVariable)校验失败时抛出。
     * 与 @RequestBody 的 MethodArgumentNotValidException 是不同的异常类型!
     */
    @ExceptionHandler(jakarta.validation.ConstraintViolationException.class)
    public Result<Void> handleConstraintViolation(jakarta.validation.ConstraintViolationException e) {
        String detail = e.getConstraintViolations().stream()
                .map(v -> v.getMessage())
                .reduce((a, b) -> a + ";" + b)
                .orElse("参数校验失败");
        return Result.fail(1003, detail);
    }

    /** 兜底 */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        return Result.fail(5000, "系统繁忙,请稍后再试");
    }
}

6.4.5 启动类与配置文件

启动类与前几章结构一致。配置文件 application.yml

yaml 复制代码
# =====================================================================
# 第06章 配置文件
# =====================================================================
server:
  port: 8106

spring:
  application:
    name: chapter06-validation

6.5 运行与验证

bash 复制代码
mvn -pl chapter06-validation -am clean package -DskipTests
cd chapter06-validation
mvn spring-boot:run

以下均为真实运行结果。

合法请求

post.json(UTF-8):

json 复制代码
{"name":"张三丰","phone":"13812345678","peopleCount":3,"budget":5000.0,"idCard":"110101199001011234","emergencyContact":{"contactName":"李四","contactPhone":"13998765432"},"preferredAttractions":["喀纳斯","稻城"]}
bash 复制代码
curl -X POST http://localhost:8106/bookings \
  -H "Content-Type: application/json; charset=utf-8" --data-binary @post.json
json 复制代码
{"code":0,"message":"success","data":"报名成功:张三丰,同行 3 人"}

多字段非法请求

bad.json:

json 复制代码
{"name":"张","phone":"12345","peopleCount":20,"budget":-5,"idCard":"abc","emergencyContact":{"contactName":"","contactPhone":"888"},"preferredAttractions":["1","2","3","4","5","6"]}
json 复制代码
{"code":1003,"message":"budget: 预算必须大于0;peopleCount: 单次最多报名10人;emergencyContact.contactName: 紧急联系人姓名不能为空;idCard: 身份证号格式不正确;phone: 手机号格式不正确;preferredAttractions: 最多选择5个偏好景点;name: 姓名长度须在2到20个字符之间;emergencyContact.contactPhone: 紧急联系人电话格式不正确","data":null}

注意 emergencyContact.xxx 前缀------嵌套校验生效了,错误能定位到具体层级。

嵌套对象缺失

json 复制代码
{"code":1003,"message":"emergencyContact: 紧急联系人不能为空","data":null}

单参数校验

bash 复制代码
curl "http://localhost:8106/bookings/quota?attractionId=5&days=99"
curl "http://localhost:8106/bookings/quota?attractionId=5&days=3"
json 复制代码
{"code":1003,"message":"天数最多30天","data":null}
{"code":0,"message":"success","data":70}

6.6 本章小结

你学会了 关键点
常用注解 @NotNull/@NotBlank/@NotEmpty 三兄弟的区别
触发开关 @Valid 开对象校验;类上 @Validated 开单参数校验
嵌套校验 对象字段上加 @Valid 才会递归
两种异常 @RequestBody → MethodArgumentNotValidException;单参数 → ConstraintViolationException
自定义注解 @Constraint 绑定校验器,message/groups/payload 三要素

6.7 动手练习

  1. 给 BookingRequest 增加 @Email 邮箱字段并验证。
  2. 编写自定义注解 @IdCard:更严格的18位身份证校验(含出生日期合法性检查)。
  3. 实现分组校验:新增时 name 必填、修改时 id 必填(提示:定义 Create/CreateUpdate 两组接口,@Validated(Create.class) 切换)。

下一章预告:内存 Map 一重启就清空------数据该"住"进数据库了。第07章《数据库连接与 JdbcTemplate》连接远程 MySQL,学习 HikariCP 连接池原理与 SQL 初始化机制。

6.8 本章使用的 Java 25 新特性

特性 说明 本章应用
record (Java 16+) 不可变数据类,自动生成构造器、getter、equals、hashCode、toString BookingRequest 用 record 替代普通类,校验注解直接标在组件上;EmergencyContact 嵌套在 BookingRequest 内部作为 record
文本块 (Java 15+) 三引号字符串,支持多行文本,无需转义换行和引 注解驱动的校验逻辑说明中使用文本块展示 @NotNull 等常用注解速查表
嵌套 record (Java 16+) record 内嵌其他 record,形成层次化数据模型 BookingRequest.EmergencyContact 作为内部 record,实现嵌套对象校验
注解与 record 结合 (Java 16+) 注解可标在 record 组件上,框架自动应用校验 Jakarta Validation 的 @NotBlank@Min@Max 等直接标在 record 组件上

为什么用现代语法? record 减少了大量样板代码,使数据模型清晰易读;配合注解校验,实现了声明式的数据验证,避免了冗余的 getter/setter 与手工 if 判断。

相关推荐
ITmaster07311 小时前
从零到一!前端搭建本地轻量化 RAG 问答系统
前端
2601_962063971 小时前
Spring Boot拦截器(Interceptor)详解
java·spring boot·后端
李昊哲小课1 小时前
SpringBoot4 云端咖啡站 阶段一:起步与基础
spring boot
夏炳辉.1 小时前
Flex布局中 flex: 1 的完整解析与实战指南
前端·css·css3
CIO_Alliance2 小时前
AI提示系列(2)| Few-shot与ReAct有何不同? 大模型工具调用的底层逻辑详解
前端·人工智能·深度学习·神经网络·react.js·前端框架·ai+ipaas
cindershade2 小时前
别只收三个数字:前端 RUM 如何建立可解释的体验数据链
前端
wangchunyu1142 小时前
Elasticsearch 入门与实战:Spring Boot 3 + ES 8 从零搭建商品搜索服务
大数据·数据库·spring boot·elasticsearch
前端 贾公子2 小时前
第09章:上下文与记忆 (4)
java·服务器·前端
Coodor2 小时前
使用web也可以写NFC微信小程序拉取
前端·微信小程序·小程序·nfc拉起小程序