【阶段 3】Web 开发:Spring MVC 与生产级 REST API

153次阅读
没有评论

版本:Spring Boot 3.2.x / JDK 17 · 前置依赖:阶段 2(Spring Boot
基础) 定位:企业后端最核心的日常技能 ——
写出「健壮、可维护、可追溯」的接口。


1. 导语

前面两个阶段,你已经能把 Spring 容器跑起来、把 Bean 装配明白,也懂了
Spring Boot
的自动配置在背后做了什么。但从「能启动一个项目」到「能交付一个接口」,中间隔着一条很长的路,这条路的名字叫
Spring MVC

Spring MVC 不是一门新语言,而是 Spring 处理 Web
请求的一整套体系。它的核心职责只有一件事:把一个 HTTP
请求,变成你 Controller 方法的一次调用,再把返回值变成 HTTP
响应
。听上去简单,但生产环境里 90%
的线上问题——参数没校验、异常没兜住、接口被重复提交、排查日志时找不到同一请求的上下文——都出在这一条链路上。

这一篇,你会完整走通这条链路:

  1. 看懂请求流程:一个请求从进入 Filter
    DispatcherServlet,再到
    Interceptor、参数校验、Controller,最后渲染返回,每一步由谁负责、在什么时候触发。
  2. 设计规范的 REST API:URL 怎么起、HTTP
    方法语义怎么用、参数怎么绑。
  3. 建立三层分离:DTO 入参、VO 出参、Entity
    入库,为什么必须分开,以及不分开会出什么事故。
  4. 搭好统一底座:统一响应体
    ApiResult、错误码 ErrorCode、业务异常
    BizException、全局异常处理器
    GlobalExceptionHandler,这四个类是你后续所有接口的公共地基,从本篇起全系列统一复用。
  5. 补齐生产级能力:幂等防重、traceId
    全链路追踪、日志脱敏、限流、文件上传、跨域。

学完这一篇,你能独立交付一个分层清晰、校验齐全、异常可控、可追踪的「用户管理」REST
服务,这也是后面数据访问、安全、微服务等所有阶段的地基——它们都建立在你写出的这些接口之上。


2. 学习目标与前置要求

学完你能…

  1. 画出一次请求从 Filter 进入、经
    Interceptor、参数校验到 Controller
    再返回的完整执行链路,并说清每一步的触发时机(面试高频)。
  2. 设计出语义规范的 RESTful API,正确使用 @RequestMapping
    家族、@PathVariable / @RequestParam /
    @RequestBody 绑定参数。
  3. 用 JSR-303 注解 + 分组校验 + 自定义校验注解,把非法数据挡在
    Controller 之外
  4. 手写 ApiResult / ErrorCode /
    BizException /
    GlobalExceptionHandler,做到「对外模糊提示、堆栈只进日志」。
  5. 区分 Filter / Interceptor / 监听器 / AOP
    的职责边界,并实现登录态拦截器 + traceId Filter。
  6. 交付一个覆盖幂等、traceId、脱敏、限流、文件上传、CORS
    的「用户管理」生产级项目。

前置依赖

  • 阶段 2(Spring Boot 基础):必须掌握
    @SpringBootApplication
    三合一注解、自动配置原理、@ConfigurationProperties
    配置读取、日志配置。若尚未掌握,建议先回头把阶段 2
    的「自动配置」和「配置管理」两节吃透。
  • 阶段 1(Spring 核心):IoC / 构造器注入 / AOP
    概念。本篇大量使用「构造器注入 + final
    字段」,并对拦截器、幂等切面用到 AOP,不理解会看得吃力。
  • Java
    基础
    :泛型、注解、枚举、Optional。尤其泛型——ApiResult<T>
    PageResult<T> 都用泛型做类型安全的返回。

数据访问层(MyBatis-Plus)在本篇第 7
章只做「够用」的展示,深度讲解在阶段 4。如果你还没接触过,不影响理解 Web
层,照着写即可。


3. 环境准备

3.1 依赖版本

组件 版本 说明
JDK 17 全系列统一,recordsealed
等新特性可用
Spring Boot 3.2.x 本文以 3.2.5 为例
Spring MVC 内嵌于 spring-boot-starter-web 无需单独声明版本
MyBatis-Plus 3.5.7 数据访问层,配套 mybatis-plus-spring-boot3-starter
MySQL 8.0 主库,本地用 Docker 起
Redis 7.x 幂等、限流的分布式存储,本地用 Docker 起
Lombok 随 Spring Boot 管理 生成 @Data@Slf4j

注意:Spring Boot 3.x 走的是 Jakarta EE 9+
命名空间,所有
javax.servlet.*javax.validation.* 都变成了
jakarta.servlet.*jakarta.validation.*。如果你从网上抄到
javax 开头的 import,八成是 Spring Boot 2.x
时代的旧代码,直接用会编译不过。

3.2 初始化项目

用 Spring Initializr 生成,或在已有项目上补依赖。核心依赖如下:

<!-- pom.xml 关键依赖 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.5</version>
    <relativePath/>
</parent>

<properties>
    <java.version>17</java.version>
</properties>

<dependencies>
    <!-- Web MVC:内嵌 Tomcat + DispatcherServlet -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 参数校验:JSR-303 / Bean Validation 3.0(Spring Boot 2.3 起需单独引入) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- AOP:幂等切面需要 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <!-- MyBatis-Plus:数据访问层(Spring Boot 3 专用 starter) -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.7</version>
    </dependency>
    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <!-- Redis:幂等、限流 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

3.3 启动本地依赖(Docker)

# MySQL 8.0
docker run -d --name mysql8 
  -e MYSQL_ROOT_PASSWORD=root 
  -e MYSQL_DATABASE=demo 
  -p 3306:3306 mysql:8.0

# Redis 7
docker run -d --name redis7 -p 6379:6379 redis:7

# 建表(第 7 章用户表)
docker exec -i mysql8 mysql -uroot -proot demo <<'SQL'
CREATE TABLE IF NOT EXISTS `user` (
  `id`         BIGINT AUTO_INCREMENT PRIMARY KEY,
  `username`   VARCHAR(50)  NOT NULL COMMENT '用户名',
  `password`   VARCHAR(100) NOT NULL COMMENT '密码(生产应存 BCrypt 摘要)',
  `email`      VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
  `phone`      VARCHAR(20)  DEFAULT NULL COMMENT '手机号',
  `age`        INT          DEFAULT NULL COMMENT '年龄',
  `status`     TINYINT      DEFAULT 1 COMMENT '状态:1=正常 0=禁用',
  `create_time` DATETIME    DEFAULT NULL COMMENT '创建时间',
  `update_time` DATETIME    DEFAULT NULL COMMENT '更新时间',
  `deleted`    TINYINT      DEFAULT 0 COMMENT '逻辑删除:0=正常 1=删除',
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
SQL

3.4 配置文件

# application.yml
server:
  port: 8080

spring:
  application:
    name: demo-web
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  data:
    redis:
      host: localhost
      port: 6379

# MyBatis-Plus 配置
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true   # 下划线转驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 开发期打印 SQL,生产关闭
  global-config:
    db-config:
      logic-delete-field: deleted        # 逻辑删除字段
      logic-delete-value: 1
      logic-not-delete-value: 0

logging:
  level:
    com.example.demo: debug

3.5 包结构(全文统一)

com.example.demo
├── controller      # 接口层:只做参数接收与结果返回
├── service         # 业务层:接口 + impl 分离
│   └── impl
├── mapper          # 数据访问层:MyBatis-Plus BaseMapper
├── entity          # 数据库实体:与表一一对应,禁止出接口
├── dto             # 入参对象:带 JSR-303 校验注解
├── vo              # 出参对象:按需裁剪字段
├── config          # 配置类:WebConfig / CorsConfig / RedisConfig 等
├── exception       # BizException / GlobalExceptionHandler
├── interceptor     # AuthInterceptor / RateLimitInterceptor
├── filter          # TraceIdFilter
├── annotation      # 自定义注解:Idempotent
├── validator       # 自定义校验:Phone / PhoneValidator
├── aop             # 切面:IdempotentAspect
└── common          # ApiResult / ErrorCode / PageResult / DesensitizeUtil

4. 正文章节

第 1 章 Spring MVC
原理与请求流程

1.1 为什么必须懂「请求流程」

很多开发者写接口写了两年,遇到「为什么我的拦截器没生效」「为什么参数校验没触发」「为什么异常被
Tomcat 的 500
页面吃掉了」这类问题时,只能靠猜。根本原因是不知道一个请求在
Spring MVC
里到底经过了哪些关卡、各关卡谁先谁后
。这一章把这根线彻底捋直,它是本篇所有内容(拦截器、校验、全局异常)的共同坐标系。

1.2 核心组件(它们分别干什么)

Spring MVC 围绕一个叫 DispatcherServlet
的「前端控制器」展开。它自己不干业务,只负责把请求分发到正确的处理器,再把结果返回。它的周边是一套协作组件:

组件 职责 一句话类比
DispatcherServlet 前端控制器,统一接收请求、分发、返回 前台总机,接电话转分机
HandlerMapping 根据 URL + HTTP 方法找到对应的处理器(Handler)+ 拦截器链 电话簿,查号码
HandlerAdapter 真正调用 Handler,负责参数解析、类型转换 分机接线员,把话转给具体人
HandlerInterceptor 拦截器,在 Handler 调用前后做增强(登录校验、限流) 前台旁边的安检
HandlerExceptionResolver 异常解析器,@ExceptionHandler
就是靠它把异常映射到处理方法
值班经理,处理突发状况
ViewResolver 解析视图(前后端分离时代已退居次席,JSON 直接由
@ResponseBody 写出)
排版员

1.3
完整执行链路(面试必考,务必背下来)

一次请求的完整生命周期如下:

客户端发起请求
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ① Filter 链(Servlet 容器层,最外层)                      │
│    编码 / CORS / 请求日志 / traceId 透传 / XSS 过滤          │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ② DispatcherServlet.doDispatch()                         │
│    唯一的入口方法,之后所有动作都在它内部完成                 │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ③ HandlerMapping.getHandler()                            │
│    根据 URL + Method 找到 Handler,同时包装好拦截器链        │
│    (HandlerExecutionChain = Handler + Interceptor[])     │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ④ HandlerAdapter.handle()                                │
│    找到能处理该 Handler 的适配器,开始真正调用               │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑤ Interceptor.preHandle()   ← 拦截器前置(可拦截返回)      │
└─────────────────────────────────────────────────────────┘
   │ 返回 true 才继续
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑥ 参数解析 + 参数校验(@Validated)                        │
│    绑定 @RequestBody/@RequestParam/@PathVariable,          │
│    执行 JSR-303 校验,失败抛 MethodArgumentNotValidException │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑦ Controller 方法执行(你的业务入口)                      │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑧ Interceptor.postHandle()   ← 拦截器后置(视图渲染前)    │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑨ 视图渲染 / @ResponseBody 序列化返回                      │
│    (返回 JSON 的场景,由 HttpMessageConverter 处理)        │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑩ Interceptor.afterCompletion() ← 拦截器收尾(一定执行)   │
└─────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────┐
│ ⑪ Filter 链返回(离开 Servlet 容器)                       │
└─────────────────────────────────────────────────────────┘
   │
   ▼
响应返回客户端

一句话记住顺序

Filter(进入)→ Interceptor.preHandle → 参数校验(@Validated) → Controller
→ Interceptor.postHandle → 视图渲染 → Interceptor.afterCompletion → Filter(返回)

关键结论:参数校验发生在进入 Controller
之前
。校验失败抛出的
MethodArgumentNotValidException,不会进 Controller,而是被
HandlerExceptionResolver 转交给全局异常处理器(第 5
章详讲)。

1.4 每一步为什么是这个顺序

  • Filter 在最外层:因为它工作在 Servlet 容器层,早于
    Spring
    的任何组件。适合做「所有请求都要做、且与业务无关」的事,比如设置字符编码、生成
    traceId。
  • 拦截器在 Filter 之内、Controller
    之外
    :因为它已经能拿到「目标
    Handler」信息(哪个方法被调了),适合做「针对具体接口」的增强,比如登录态校验(放行某些路径)、限流。
  • 校验在 Controller 之前:因为 Spring
    的目标是「脏数据不进门」,让 Controller
    拿到的一定是干净参数,业务代码不需要写一堆
    if (dto.getName() == null)
  • afterCompletion
    一定执行
    :即使前面抛了异常,它也会在 finally
    里被调用,所以适合做「资源清理、请求耗时统计」。

1.5
最小可用示例:一个能跑的 Controller

先用最小示例验证整条链路是通的:

package com.example.demo.controller;

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

/**
 * 最小示例:验证请求能到达 Controller 并返回 JSON
 * @RestController = @Controller + @ResponseBody
 *   @Controller:声明这是一个处理器组件,交给 Spring 管理
 *   @ResponseBody:方法返回值直接序列化为 JSON 写入响应体,而非走视图渲染
 */
@RestController
@RequestMapping("/api/hello")
public class HelloController {

    @GetMapping
    public String hello(@RequestParam(defaultValue = "World") String name) {
        // 直接返回字符串,@ResponseBody 会把它写进响应体
        return "Hello, " + name;
    }
}

启动项目后访问
http://localhost:8080/api/hello?name=Spring,返回:

Hello, Spring

1.6
生产级提示:三个「拦截点」的区别要记牢

很多人分不清 Filter、Interceptor、AOP,这里先给出结论,第 6
章再展开代码:

组件 层级 能否拿到目标方法 能否拿到 Spring Bean 典型用途
Filter Servlet 容器层 不能(只有 HttpServletRequest) 较难(要手动从容器取) 编码、CORS、traceId
Interceptor Spring MVC 层 能(Handler 参数) 能(本身是 Spring Bean) 登录态、权限、限流
AOP 方法层 能(Method 对象) 日志、事务、幂等

1.7
HttpMessageConverter:JSON 是怎么「变」出来的

@ResponseBody 说「返回值直接写进响应体」,但
UserVO 是 Java 对象,怎么变成 JSON 字节流的?靠的是
HttpMessageConverter(消息转换器)链。Spring 按「请求
Content-Type + 目标类型」从转换器链里挑一个能处理的:

  • MappingJackson2HttpMessageConverter:Java 对象 ↔︎
    JSON(依赖 Jackson,starter-web 已内置)。
  • StringHttpMessageConverter:String ↔︎ 文本。
  • ResourceHttpMessageConverter:文件下载场景。

所以「返回 JSON」不是魔法,而是 @ResponseBody
标记方法后,HandlerAdapter 写完 Controller
返回值时,遍历转换器链找到 Jackson 转换器,调用
ObjectMapper 完成序列化。反过来,@RequestBody
绑定 JSON 也是同一条链:读请求体 → 找到 Jackson 转换器 → 反序列化成
DTO。

这个环节有两个高频实际问题:

  1. LocalDateTime
    被序列化成数组
    :Jackson 默认把 LocalDateTime 写成
    [2026,8,14,10,0,0] 这种数组,前端没法用。解决:字段加
    @JsonFormat(详见第 3 章 3.8)。

  2. 循环引用导致序列化栈溢出:实体间双向关联(Order
    → User → Order)会无限递归。解决:出参用 VO 断开循环引用,或字段加
    @JsonIgnore。这是「VO
    出参」的又一个隐藏价值——从结构上避免把整张对象图塞进响应。

本章小结:一次请求按
Filter → Interceptor.preHandle → 校验 → Controller → postHandle → 视图渲染 → afterCompletion → Filter 返回
的顺序流转,DispatcherServlet 是总调度,校验发生在 Controller
之前。这套顺序是后续所有章节的坐标系。


第 2 章 RESTful API 设计

2.1 REST 是什么、为什么用它

REST(Representational State
Transfer,表现层状态转移)不是协议,而是一套约定:把服务端的一切都抽象成「资源」(Resource),用
URL 定位资源,用 HTTP 方法表达对资源的操作。

为什么生产环境几乎清一色 REST
风格,而不是「/api/getUserById/api/deleteUser」这种「动作命名」风格?

  1. 语义自解释GET /api/users/1
    一看就知道是查 id=1 的用户,不需要文档。
  2. 与 HTTP 协议对齐:HTTP 本身就定义了
    GET/POST/PUT/DELETE 的语义,REST
    只是把业务「挂」到这个标准协议上,能免费享受缓存、幂等、中间件等 HTTP
    生态能力。
  3. 前后端分离友好:URL 稳定、返回
    JSON,前端、App、第三方都能消费同一套接口。

2.2 HTTP
方法语义(用错是生产事故)

方法 语义 是否幂等 是否安全 典型场景
GET 查询资源 查单个、查列表
POST 创建资源 新增用户、下单
PUT 全量更新资源 更新整个对象
PATCH 部分更新 只改一个字段
DELETE 删除资源 删除用户

幂等:同一个请求重复发多次,结果与发一次相同。GETPUTDELETE
天然幂等;POST 不幂等(重复 POST
会创建多条)。「是否安全」指是否会改变服务端状态,GET
是安全的。

生产事故案例:把「扣款」这类操作写成
GET,结果被浏览器预加载、搜索引擎爬虫反复触发,重复扣款。凡是改变状态的操作,绝不能用
GET。

2.3 URL 设计规则

  1. 资源用名词复数,不用动词:/api/users(对),/api/getUsers(错)。
  2. 层级表达从属关系/api/users/1/orders(用户的订单)。
  3. 用路径参数定位资源,用查询参数做筛选/分页:/api/users/1(定位)+
    /api/users?page=1&size=10(筛选)。
  4. 版本号放 URL 或请求头/api/v1/users
    Accept: application/vnd.demo.v1+json
  5. 统一小写 +
    短横线
    /api/order-items(对),/api/orderItems(错)。

2.4 映射注解家族

Spring MVC 用 @RequestMapping 家族把 URL
和方法绑定。各注解是 @RequestMapping
的「特化版」,语义更清晰:

注解 等价写法 用途
@GetMapping @RequestMapping(method = GET) 查询
@PostMapping @RequestMapping(method = POST) 创建
@PutMapping @RequestMapping(method = PUT) 全量更新
@PatchMapping @RequestMapping(method = PATCH) 部分更新
@DeleteMapping @RequestMapping(method = DELETE) 删除
@RequestMapping 基础注解 类级别统一前缀、多方法匹配

类上的 @RequestMapping("/api/users")
公共前缀,方法上的 @GetMapping("/{id}")
子路径,二者拼接成完整路径
/api/users/{id}

2.5 参数绑定三兄弟

注解 参数来源 适用场景 示例
@PathVariable URL 路径段 定位资源 /api/users/{id}
@RequestParam URL 查询串 / 表单 筛选、分页 ?page=1&size=10
@RequestBody 请求体(JSON) 提交复杂对象 POST 的 DTO

三者绑定规则的关键点:

package com.example.demo.controller;

import com.example.demo.dto.UserCreateDTO;
import com.example.demo.vo.UserVO;
import com.example.demo.common.ApiResult;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserControllerDemo {

    // 1. @PathVariable:占位符 {id} 的值绑定到参数 id
    @GetMapping("/{id}")
    public ApiResult<UserVO> getById(@PathVariable Long id) {
        return ApiResult.ok(new UserVO());
    }

    // 2. @RequestParam:查询串参数,可给默认值
    //    required=false 表示可不传;defaultValue 表示缺省值
    @GetMapping
    public ApiResult<List<UserVO>> list(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String keyword) {
        return ApiResult.ok(List.of());
    }

    // 3. @RequestBody:把 JSON 请求体反序列化为 DTO
    @PostMapping
    public ApiResult<Long> create(@RequestBody UserCreateDTO dto) {
        return ApiResult.ok(1L);
    }
}

注意:@RequestParam 缺省时如果没传,Spring 会抛
MissingServletRequestParameterException(默认 400)。用
required = falsedefaultValue 可以避免「裸
400」,但这属于「防御性绑定」;真正的业务校验交给第 3 章的 JSR-303。

2.6 用户 CRUD
接口设计(本阶段贯穿示例)

把第 2 章的规则落到「用户管理」上,得到本阶段反复使用的接口清单:

方法 URL 说明 入参 出参
GET /api/users/{id} 查单个用户 @PathVariable id ApiResult<UserVO>
GET /api/users 分页查列表 @RequestParam page/size/keyword ApiResult<PageResult<UserVO>>
POST /api/users 创建用户 @RequestBody UserCreateDTO ApiResult<Long>
PUT /api/users/{id} 全量更新 @RequestBody UserUpdateDTO ApiResult<Void>
DELETE /api/users/{id} 删除用户(逻辑删除) @PathVariable id ApiResult<Void>

这套接口贯穿全篇:第 3 章给它补校验,第 4、5
章给它套统一响应和异常,第 6 章给它加登录拦截,第 7
章把它写成完整可运行项目。

2.7 HTTP 状态码:REST
语义的另一半

REST 除了 URL
和方法,还有状态码表达结果。虽然本篇统一响应体用
code 字段表达业务结果、HTTP 层恒为
200,但理解状态码是设计规范 API 的基本功:

状态码 含义 典型场景
200 OK 成功 查询/更新/删除成功
201 Created 创建成功 POST 创建资源
204 No Content 成功但无返回体 删除成功
400 Bad Request 请求参数错误 参数校验失败
401 Unauthorized 未认证 未登录
403 Forbidden 已认证但无权限 越权访问
404 Not Found 资源不存在 查不存在的 id
409 Conflict 资源冲突 重复提交
429 Too Many Requests 限流 触发限流
500 Internal Server Error 服务端错误 兜底异常

两种「统一响应」流派:① HTTP 状态码恒
200,业务结果全看
body.code(本篇采用的方案,前端只需一套解析);② HTTP
状态码真实反映结果(201/400/404),body
只带细节。前者在国内前后端分离场景更主流,因为它绕开了「某些网关/代理会吞掉非
200 状态码的 body」这类兼容性问题。

两种方案都能用,但同一项目只能选一种并贯彻到底,最怕一半接口
200 恒真、一半接口按状态码来,前端要写两套处理逻辑。

2.8
三层分离的第一课:DTO / VO / Entity 为什么必须分开

本章的 CRUD
接口里已经出现了三个「长得像」的对象:UserCreateDTO(入参)、UserVO(出参)、后面第
7 章还有 User(实体)。很多人图省事想「一个
User
从头用到尾」,这是生产事故的头号来源。三个对象必须分开,各有各的职责:

对象 职责 关键特征 禁止行为
EntityUser 数据库映射 带 MyBatis-Plus 注解,含 passworddeleted
等内部字段
禁止接请求、禁止返回响应
DTOUserCreateDTO 入参对象 带 JSR-303 校验注解,只含「前端能提交」的字段 禁止带数据库注解
VOUserVO 出参对象 按需裁剪,剔除敏感字段(如 password 禁止带数据库注解

不分开会出什么具体事故

  1. 用 Entity 返回响应User
    password 字段,JSON
    序列化时把密码明文回给前端——这是安全事件,不是风格问题。
  2. 用 Entity 接请求:前端传一个
    {"deleted": 0, "id": 1}
    就能改逻辑删除标记甚至主键,造成「批量绑定」漏洞(Mass
    Assignment)。
  3. DTO 和 VO 复用同一个对象:创建时
    password
    必填,但列表返回时又得想办法「隐藏」password,只能用
    @JsonIgnore 到处打补丁,字段越加越乱。

所以三层分离不是「洁癖」,而是用类型系统强制约束数据流:数据只能沿
DTO → Entity → VO
单向流动,每过一层都被裁剪和校验一次。这个思想贯穿本系列所有文章,第 7
章会看到它的完整落地。

本章小结:REST 用「名词 URL 定位资源 + HTTP
方法表达操作」,@PathVariable
定位、@RequestParam 筛选、@RequestBody
承载复杂对象;改变状态的操作绝不能用 GET。

第 3 章 参数绑定与校验

3.1
为什么「校验」是生产级的分水岭

接口的输入永远是「不可信的」:可能来自被篡改的客户端、爬虫、或者别人的
bug。如果 Controller 拿到什么就往
Service、数据库里塞,脏数据会像污水一样流遍整个系统,最后在数据库里沉淀成难以清理的历史包袱。

生产级的做法是:在边界处一次性拦住非法数据。这个「边界」就是
Controller 的参数入口,工具就是 JSR-303(Bean
Validation)校验。它的价值有三层:

  1. 前置拦截:非法数据根本进不了业务方法,Service
    层不需要写防御性 if
  2. 声明式:校验规则以注解形式写在 DTO
    字段上,规则和字段「贴在一起」,可读性极强。
  3. 错误信息友好:校验失败抛出
    MethodArgumentNotValidException,由全局异常处理器转成统一格式返回(第
    5 章)。

关键认知:校验只防「格式错误」,不防「业务错误」@Email
能保证字符串长得像邮箱,但保证不了「这个邮箱没被注册过」——后者是 Service
层的业务校验(查库、抛
BizException)。两层各司其职,不要混。

3.2 常用校验注解速查

注解 校验规则 适用类型
@NotNull 不能为 null 任意对象
@NotBlank 不能为 null 且去掉首尾空白后长度 > 0 String
@NotEmpty 不能为 null 且不能为空(集合 size>0 / 字符串 length>0) String、Collection、Map、数组
@Size(min, max) 长度/元素个数在区间内 String、Collection、Map、数组
@Min / @Max 数值不小于 / 不大于 数值类型
@Email 邮箱格式 String
@Pattern(regexp) 正则匹配 String
@Past / @Future 过去 / 未来时间 日期时间类型
@Digits(integer, fraction) 整数位与小数位限制 数值

三个「空」的区别,是面试和写 bug 的重灾区:

  • @NotNull""(空串)和
    " "(空白串)都能通过,只有
    null 不行。
  • @NotEmptynull""
    不行,但 " "(纯空白)能通过
  • @NotBlanknull""" "
    都不能通过。校验用户名/密码这种「必须填内容」的字段,一律用
    @NotBlank

3.3 @Valid
@Validated 的区别

很多教程把这两个注解混着用,实际上有明确分工:

注解 来源 能否分组 典型位置
@Valid JSR-303 标准(jakarta.validation.Valid 不能分组 嵌套对象校验(List<ItemDTO>
@Validated Spring
扩展(org.springframework.validation.annotation.Validated
能分组 Controller 参数上,触发校验

生产约定:Controller 入参一律用
@Validated,因为它能配合分组校验(见 3.5)。校验 DTO
内部嵌套的集合对象时,在字段上用 @Valid
做「级联校验」。

3.4 最小可用:给 DTO 加校验

package com.example.demo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

/**
 * 用户创建入参 DTO
 * 只承载「入参 + 校验规则」,绝不参与数据库映射,也不直接返回给前端。
 */
@Data
public class UserCreateDTO {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3-20 之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 32, message = "密码长度必须在 6-32 之间")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Min(value = 1, message = "年龄必须大于 0")
    @Max(value = 150, message = "年龄必须小于 150")
    private Integer age;
}

Controller 侧在参数前加 @Validated
才会触发校验

@PostMapping
public ApiResult<Long> create(@Validated @RequestBody UserCreateDTO dto) {
    return ApiResult.ok(userService.create(dto));
}

高频坑:只写校验注解、不写
@Validated,校验完全不生效
。因为 Spring
只有在参数上看到 @Validated/@Valid
时,才会在参数绑定后触发校验器。第 7 章的「常见坑」会再强调。

3.5 生产级:分组校验

一个 DTO
同时服务「创建」和「更新」两个场景时,字段的校验规则往往不同:创建时密码必填,更新时密码可不传(不传
= 不修改)。用分组校验解决。

先定义两个空标记接口作为分组:

package com.example.demo.dto;

// 创建分组:创建时校验的规则挂到这个分组
public interface Create {}
package com.example.demo.dto;

// 更新分组:更新时校验的规则挂到这个分组
public interface Update {}

然后在 DTO 字段上给注解标注 groups

package com.example.demo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class UserUpsertDTO {

    // 不写 groups 的注解属于默认分组,创建/更新都校验
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3-20 之间")
    private String username;

    // 只在「创建」时校验密码
    @NotBlank(groups = Create.class, message = "密码不能为空")
    @Size(groups = Create.class, min = 6, max = 32, message = "密码长度必须在 6-32 之间")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Min(value = 1, message = "年龄必须大于 0")
    @Max(value = 150, message = "年龄必须小于 150")
    private Integer age;
}

Controller 用 @Validated({Create.class})
指定本次校验哪个分组:

@PostMapping
public ApiResult<Long> create(@Validated(Create.class) @RequestBody UserUpsertDTO dto) {
    return ApiResult.ok(userService.create(dto));
}

@PutMapping("/{id}")
public ApiResult<Void> update(@PathVariable Long id,
                              @Validated(Update.class) @RequestBody UserUpsertDTO dto) {
    userService.update(id, dto);
    return ApiResult.ok(null);
}

注意:一旦 DTO 里任何一个字段标注了
groups,那么没标 groups 的注解只属于
Default 默认组。此时用
@Validated(Create.class) 只会校验 Create 组和
Default 组……实际上规则是:只校验你指定的分组 +
默认组里没被显式分组的注解
。要精确控制,建议把每个注解的组都写清楚,避免歧义。上面示例中
username/email/age
属于默认组,@Validated(Create.class)
会连同默认组一起校验;password 只属于 Create
组,所以更新时不校验它。

3.6 生产级:自定义校验注解
@Phone

内置注解覆盖不了业务里的「手机号格式」这类规则。用「自定义注解 +
校验器」两步实现。

第一步:定义注解 @Phone,用
@Constraint 指定校验器:

package com.example.demo.validator;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

/**
 * 手机号校验注解:值为空时跳过(空校验交给 @NotBlank),非空时按正则校验。
 */
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)   // 指定真正干活的校验器
public @interface Phone {

    // 校验失败时的提示(可在使用时覆盖)
    String message() default "手机号格式不正确";

    // 以下三个是 Bean Validation 规范强制要求的属性,照抄即可
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

第二步:实现校验器 PhoneValidator

package com.example.demo.validator;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.regex.Pattern;

/**
 * @Phone 的校验器。ConstraintValidator<注解, 校验的字段类型>
 */
public class PhoneValidator implements ConstraintValidator<Phone, String> {

    // 中国大陆手机号:1 开头,第二位 3-9,共 11 位
    private static final Pattern PHONE_PATTERN =
            Pattern.compile("^1[3-9]d{9}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // null 或空串:交给 @NotBlank 处理,这里放行,避免重复报错
        if (value == null || value.isEmpty()) {
            return true;
        }
        return PHONE_PATTERN.matcher(value).matches();
    }
}

使用:

@Data
public class UserCreateDTO {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3-20 之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 32, message = "密码长度必须在 6-32 之间")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Phone(message = "手机号格式不正确")   // 自定义注解
    private String phone;

    @Min(value = 1, message = "年龄必须大于 0")
    @Max(value = 150, message = "年龄必须小于 150")
    private Integer age;
}

3.7 嵌套对象与集合校验

当 DTO 里嵌了另一个对象或集合,只加 @Validated
不会级联校验到内层,必须在字段上再加 @Valid

@Data
public class OrderCreateDTO {
    @NotBlank(message = "订单号不能为空")
    private String orderNo;

    // 集合元素也必须校验:@Valid 触发级联,否则 List<ItemDTO> 里的字段校验不生效
    @NotEmpty(message = "订单项不能为空")
    @Valid
    private List<OrderItemDTO> items;
}

3.8
日期时间绑定:@DateTimeFormat 与 @JsonFormat

日期字段是「格式错误」的高发区。两个注解分工不同,用错会导致日期解析成
null 或直接抛异常:

  • @DateTimeFormat(pattern = "yyyy-MM-dd"):作用于入参,控制「字符串
    → 日期」,用于 @RequestParam / @ModelAttribute
    的日期参数绑定。
  • @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"):作用于出入参,控制
    Jackson 的序列化/反序列化,用于 @RequestBody DTO 和
    @ResponseBody VO 的日期字段。
@Data
public class UserCreateDTO {
    // @RequestBody 场景用 @JsonFormat,而非 @DateTimeFormat
    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    private LocalDate birthday;
}

// @RequestParam 场景用 @DateTimeFormat
@GetMapping("/born-before")
public ApiResult<List<UserVO>> bornBefore(
        @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
    return ApiResult.ok(userService.listBornBefore(date));
}

记牢一条:Jackson 反序列化(@RequestBody)认
@JsonFormat,Spring
参数绑定(@RequestParam/@ModelAttribute)认
@DateTimeFormat
。用错了,要么日期变
null,要么抛
MethodArgumentTypeMismatchException(好在第 5
章已经兜住了它)。

3.9
编程式校验与快速失败(进阶)

注解校验覆盖了 95% 的场景,但有两种情况需要了解「底层怎么跑」:

  1. 编程式校验:不依赖注解,手动调用
    Validator。用于「DTO
    无法加注解」或「动态决定校验规则」的场景。
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class SomeService {

    private final Validator validator;   // Spring 自动注入 javax.validation.Validator

    public void check(Object obj) {
        Set<ConstraintViolation<Object>> violations = validator.validate(obj);
        if (!violations.isEmpty()) {
            String msg = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining("; "));
            throw new BizException(ErrorCode.PARAM_ERROR.getCode(), msg);
        }
    }
}
  1. 快速失败(fail-fast):默认情况下,@Validated
    会把一个 DTO
    所有字段的校验错误都收集起来一次性返回(如「用户名长度…;
    密码长度…; 手机号格式…」)。如果想让「第一个错就停」,配置
    Validator 开启快速失败:
import jakarta.validation.Validation;
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ValidatorConfig {

    @Bean
    public jakarta.validation.Validator validator() {
        // 开启快速失败:任一字段校验失败即停止,不继续校验后续字段
        return Validation.byProvider(HibernateValidator.class)
                .configure()
                .failFast(true)
                .buildValidatorFactory()
                .getValidator();
    }
}

生产建议:默认全量收集对用户体验更好(一次提交能看见所有错误);快速失败适合「字段间有依赖、后续字段校验依赖前面字段通过」的场景。二选一,别混用。

本章小结:用 JSR-303 注解把「格式校验」挡在
Controller 门口,@Validated 负责触发、groups
区分场景、@Constraint +
自定义校验器扩展规则;@NotBlank/@NotEmpty/@NotNull
的空值语义务必分清。


第 4 章 统一响应体

4.1 为什么必须统一响应体

如果每个接口的返回格式都不一样——这个返回
{success: true, data: ...},那个直接返回裸对象,另一个返回
{code: 1, msg: "xx"}——前端要写 N
套解析逻辑,联调成本爆炸,出问题时也难定位。

统一响应体解决三个问题:

  1. 结构统一:所有接口返回
    {code, message, data, traceId},前端只需一套解析逻辑。
  2. 错误表达统一:成功和失败走同一个结构,只是
    codemessage 不同,前端能统一弹提示。
  3. 可追溯traceId
    字段把「响应」和「后端日志」关联起来,用户报错时带着
    traceId 来,你能瞬间定位到那一次请求的所有日志。

4.2 ApiResult
完整实现(全系列统一类,逐字复用)

下面这个 ApiResult<T> 是贯穿全系列 12
篇文章的公共地基类,类名、字段名、方法签名与总规范完全一致,后续所有文章复用,不再重定义:

package com.example.demo.common;

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}

设计要点:

  • 泛型 <T>data
    字段类型随业务变化,ApiResult<UserVO>
    ApiResult<Long> 都是它,类型安全。
  • 静态工厂方法:把「如何构造一个正确的结果」收口到
    ok/fail 两个方法里,调用方不可能漏设
    codemessage
  • traceId 取自 MDCMDC
    SLF4J 的「线程上下文」机制,第 6 章的 TraceIdFilter
    会在请求进入时把 traceId 塞进 MDC,这里直接读出来回传给前端。没有 Filter
    时读到 null,不影响运行。

4.3 ErrorCode
错误码枚举(全系列统一类)

ApiResult.fail(ErrorCode ec)
需要错误码。把错误码收敛成枚举,避免代码里散落「魔法数字」:

package com.example.demo.common;

/**
 * 统一错误码枚举。
 * 基础码(SUCCESS ~ SYSTEM_ERROR)与总规范逐字一致,数值不变,全系列复用。
 * 各篇可按需在下方追加业务错误码,但不得修改基础码。
 */
public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),

    // ===== 本阶段追加的错误码(仅追加,不修改上面基础码) =====
    TOO_MANY_REQUESTS(42901, "请求过于频繁,请稍后重试"),
    DUPLICATE_SUBMIT(40901, "请勿重复提交"),
    FILE_UPLOAD_ERROR(40003, "文件上传失败"),
    ;

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

错误码分段约定(参考阿里规约思路):

段位 含义
0 成功
400xx 参数/客户端错误
401xx 未认证
403xx 无权限
404xx 资源不存在
429xx 限流
409xx 冲突(如重复提交)
500xx 服务端错误

4.4 最小可用 vs
生产级:正确用法

错误示范(直接返回裸对象):

@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {   // 前端拿到的是裸 User,结构不统一
    return userService.getById(id);
}

正确示范(统一包装):

@GetMapping("/{id}")
public ApiResult<UserVO> getById(@PathVariable Long id) {
    UserVO vo = userService.getById(id);
    return ApiResult.ok(vo);   // 成功:code=0, message=success, data=vo
}

@PostMapping
public ApiResult<Long> create(@Validated @RequestBody UserCreateDTO dto) {
    Long id = userService.create(dto);
    return ApiResult.ok(id);   // 创建成功返回新 id
}

实际返回 JSON 示例:

{
  "code": 0,
  "message": "success",
  "data": {
    "id": 1,
    "username": "zhangsan",
    "email": "zhangsan@example.com"
  },
  "traceId": "a1b2c3d4e5f6"
}

4.5 分页响应 PageResult

列表接口的 data
通常要带分页信息。定义一个通用的分页包装类(放在 common
包):

package com.example.demo.common;

import lombok.Data;

import java.util.List;

/**
 * 通用分页结果:列表接口的 data 统一用它承载。
 *
 * @param <T> 列表元素类型
 */
@Data
public class PageResult<T> {
    private long total;        // 总记录数
    private int page;          // 当前页码(从 1 开始)
    private int size;          // 每页条数
    private List<T> records;   // 当前页数据

    public static <T> PageResult<T> of(long total, int page, int size, List<T> records) {
        PageResult<T> result = new PageResult<>();
        result.setTotal(total);
        result.setPage(page);
        result.setSize(size);
        result.setRecords(records);
        return result;
    }
}

于是列表接口长这样:

@GetMapping
public ApiResult<PageResult<UserVO>> list(
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "10") int size) {
    PageResult<UserVO> pageResult = userService.listUsers(page, size);
    return ApiResult.ok(pageResult);
}

本章小结ApiResult<T>(含
code/message/data/traceId 四字段 +
ok/fail 工厂方法)与 ErrorCode
枚举是全系列公共地基,复用阶段 1
定义,之后所有接口统一复用,禁止重定义。


第 5 章 全局异常处理

5.1 为什么不能靠 try-catch

没有统一异常处理时,常见两种坏味道:

// 坏味道一:Controller 里到处 try-catch,逻辑被异常处理淹没
@PostMapping
public ApiResult<Long> create(@RequestBody UserCreateDTO dto) {
    try {
        Long id = userService.create(dto);
        return ApiResult.ok(id);
    } catch (Exception e) {
        log.error("创建失败", e);
        return ApiResult.fail(50000, "系统错误");
    }
}

// 坏味道二:什么都不 catch,异常直接冒泡成 Tomcat 的 500 错误页
// 返回的是 HTML 而非 JSON,前端解析直接崩

统一异常处理要达成的目标:

  1. 收口:所有异常在一个地方处理,Controller
    保持干净。
  2. 统一格式:异常也返回
    ApiResult,前端错误处理逻辑和成功路径一致。
  3. 分层记录:可预期异常记
    warn,不可预期异常记 error 并带完整堆栈。
  4. 防泄露:对外只给模糊提示,内部细节(SQL、类名、栈)只进日志,不给前端。

5.2 BizException
业务异常(全系列统一类)

业务逻辑里「不符合业务规则」的情况(用户不存在、库存不足、重复提交),用
BizException 显式抛出。它是 RuntimeException
的子类,携带错误码:

package com.example.demo.exception;

import com.example.demo.common.ErrorCode;

/**
 * 统一业务异常:可预期的业务错误用它显式抛出。
 * 携带错误码,由全局异常处理器统一捕获并转成 ApiResult
 */
public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

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

    public int getCode() {
        return code;
    }
}

业务代码里这样抛:

User user = userMapper.selectById(id);
if (user == null) {
    // 抛业务异常,而不是返回 null 让上层猜
    throw new BizException(ErrorCode.USER_NOT_FOUND);
}

5.3
GlobalExceptionHandler 全局异常处理器(全系列统一类)

核心是 @RestControllerAdvice +
@ExceptionHandler@RestControllerAdvice =
@ControllerAdvice +
@ResponseBody,意味着处理方法返回的对象会直接序列化为
JSON(正好是 ApiResult)。

package com.example.demo.exception;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

/**
 * 全局异常处理器:三类异常 + 若干常见框架异常,统一转 ApiResult
 * 原则:对外模糊提示,堆栈只进日志。
 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 1. 业务异常(可预期):提示直接透传给前端
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBiz(BizException e) {
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 2. 参数校验异常(@Validated 触发):拼出所有字段错误信息
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .findFirst()
            .map(f -> f.getField() + " " + f.getDefaultMessage())
            .orElse(ErrorCode.PARAM_ERROR.getMessage());
        log.warn("参数校验失败: {}", msg);
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 3. 兜底异常(不可预期):记完整堆栈,对外只给模糊提示
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleOther(Exception e) {
        log.error("系统异常", e);   // 完整堆栈进日志,便于排查
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);  // 对外不暴露任何细节
    }
}

三类异常的定位

异常 性质 日志级别 对外提示
BizException 可预期业务错误 warn 透传业务提示(如「用户不存在」)
MethodArgumentNotValidException 参数校验失败 warn 拼出的校验错误信息
Exception 不可预期系统错误 error + 堆栈 模糊提示「系统繁忙」

为什么兜底异常要「模糊提示」?因为 e.getMessage()
里可能包含
SQL、表结构、类名、甚至敏感数据,直接返回给前端等于把内部信息泄露给攻击者。正确姿势:堆栈
log.error 进日志,对外只给 SYSTEM_ERROR
的固定文案

5.4
生产级扩展:补齐常见框架异常

只有上面三类还不够。生产里还有这些「框架层异常」会冒泡,不处理就变成裸
400/405:

    // 4. 请求体缺失/JSON 解析失败(如 body 为空、字段类型对不上)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ApiResult<Void> handleNotReadable(HttpMessageNotReadableException e) {
        log.warn("请求体解析失败: {}", e.getMessage());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), "请求体格式错误");
    }

    // 5. 缺少必填请求参数(@RequestParam required=true 但没传)
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ApiResult<Void> handleMissingParam(MissingServletRequestParameterException e) {
        log.warn("缺少参数: {}", e.getParameterName());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), "缺少参数: " + e.getParameterName());
    }

    // 6. 参数类型不匹配(如 /api/users/abc 传给 Long id)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ApiResult<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
        log.warn("参数类型不匹配: {} = {}", e.getName(), e.getValue());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), "参数类型错误: " + e.getName());
    }

    // 7. 请求方法不支持(GET 请求了只支持 POST 的接口)
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ApiResult<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
        log.warn("请求方法不支持: {}", e.getMethod());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), "请求方法不支持: " + e.getMethod());
    }

    // 8. 静态资源/路径不存在(Spring Boot 3.2 用 NoResourceFoundException,不是旧版的 NoHandlerFoundException)
    @ExceptionHandler(NoResourceFoundException.class)
    public ApiResult<Void> handleNoResource(NoResourceFoundException e) {
        log.warn("资源不存在: {}", e.getResourcePath());
        return ApiResult.fail(40404, "请求的资源不存在");
    }

把上面 4–8 补充进 GlobalExceptionHandler
类后,一个完整的生产级异常处理体系就成型了。

关于 404 的坑:Spring Boot 3.2 里「路径不存在」抛的是
NoResourceFoundException,而且默认行为下它往往被
Exception.class 兜底处理器捕获。如果你单独写了
@ExceptionHandler(NoHandlerFoundException.class)(旧版类名),在
3.2 里根本不会触发。务必用
NoResourceFoundException

5.5
@RestControllerAdvice@ControllerAdvice
的区别

注解 返回值处理 适用场景
@ControllerAdvice 返回值走视图解析(ModelAndView) 传统 MVC 返回页面
@RestControllerAdvice 返回值直接序列化为 JSON 前后端分离,返回 ApiResult

前后端分离的项目一律用
@RestControllerAdvice
,否则你的
ApiResult 会被当成视图名去解析,报「找不到视图」的错。

5.6
一次异常从抛出到响应的完整路径

Controller/Service 抛异常
   │
   ▼
HandlerAdapter 捕获(异常不会往外冒给 Tomcat)
   │
   ▼
HandlerExceptionResolver 链
   │
   ▼
ExceptionHandlerExceptionResolver 找到 @ExceptionHandler 匹配的方法
   │
   ▼
GlobalExceptionHandler.handleBiz(...)  执行
   │
   ▼
返回 ApiResult 序列化为 JSON(@RestControllerAdvice 保证)
   │
   ▼
写入响应,afterCompletion 收尾

关键:@ExceptionHandler
方法的匹配是按「异常类型」找最精确的。BizException 会被
handleBiz
精确命中;MethodArgumentNotValidException
handleValid 命中;其余全部落到 handleOther
兜底。所以「兜底」一定要有,否则异常会冒泡到容器,变成默认 500
错误页。

5.7
补充:文件上传超限与多个 Advice 的匹配顺序

文件上传超限spring.servlet.multipart.max-file-size
是框架硬限制,超限抛
MaxUploadSizeExceededException,建议在全局异常处理器补一条,返回友好提示:

    // 文件上传超过大小限制(放在 GlobalExceptionHandler 类内)
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ApiResult<Void> handleMaxUpload(MaxUploadSizeExceededException e) {
        log.warn("上传文件超限: {}", e.getMessage());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), "上传文件超过大小限制");
    }

多个 @RestControllerAdvice
的匹配顺序
:当项目里有多个 advice 类都能处理同一异常时,Spring
按「advice 类的加载顺序」匹配,优先级可通过 @Order
控制(值越小越优先)。生产建议:全局异常处理集中在一个类里,避免散落多个
advice 导致「改了 A 没改 B,异常处理行为不一致」。

本章小结BizException
承载可预期业务错误,GlobalExceptionHandler
@RestControllerAdvice 统一把三类异常(业务/校验/兜底)转成
ApiResult,兜底异常「记堆栈、给模糊提示」防泄露;@RestControllerAdvice
保证异常响应也是 JSON。

第 6 章 Filter /
Interceptor / 监听器

6.1 四个「切点」的职责边界

Spring
生态里有四样东西都能「在请求处理过程中插入一段逻辑」:FilterInterceptorListenerAOP。它们的区别是面试高频题,也是生产里用错重灾区。核心区分维度是层级能否拿到
Spring 上下文

组件 层级 触发时机 能否拿目标方法 能否注入 Spring Bean 生产用途
Filter Servlet 容器层 进入 DispatcherServlet 之前 / 响应离开之后 不能 较难(要手动从容器取) 编码、CORS、请求日志、traceId 透传、XSS 过滤
Interceptor Spring MVC 层 Handler 调用前后(preHandle / postHandle / afterCompletion) 能(handler 参数) 能(本身是 @Component 登录态校验、权限、限流
Listener Servlet / Spring 容器层 容器生命周期事件(启动、会话创建等) 不能 能(@Component 应用启动初始化、会话监听
AOP 方法层 任意 Bean 方法调用前后 能(Method 对象) 日志、事务、缓存、幂等

一句话记忆:Filter 管「进出门」,Interceptor 管「进
Controller 前」,Listener 管「容器大事」,AOP
管「方法级增强」

为什么登录校验放 Interceptor 而不放 Filter?

  • Interceptor 是 Spring Bean,能直接注入
    TokenService,拿到用户信息;Filter 想用 Bean
    得绕一圈手动取容器。
  • Interceptor 能通过 handler
    参数判断「这个请求最终会走到哪个方法」,还能配合
    excludePathPatterns 精确放行登录接口。
  • Filter 适合做「无差别、与业务无关」的事——比如 traceId
    生成,它甚至早于 Spring 容器接管请求。

6.2 执行顺序再确认

Filter.doFilter 进入
  → Interceptor.preHandle(返回 false 则中断)
    → 参数校验(@Validated)
      → Controller 方法
    → Interceptor.postHandle
  → 视图渲染 / JSON 序列化
  → Interceptor.afterCompletion
Filter 返回

三个关键结论,务必记住:

  1. preHandle 返回 false
    会中断后续流程
    :后面的 Interceptor、Controller
    都不会执行。这是「拦截」的实现方式。但生产里更常用「抛
    BizException
    中断」,因为抛异常能被全局异常处理器接管,返回统一格式,而返回
    false 只会得到一个空响应。
  2. afterCompletion 一定执行:即使
    preHandle 抛异常、Controller 抛异常,它也在
    finally 语义下被调用,适合做资源清理和耗时统计。
  3. Filter 的 @Order 决定多个 Filter
    的先后
    ,值越小越先执行;traceId Filter 要设
    @Order(1) 保证最先跑。

6.3 Filter 实战:traceId
全链路追踪

「全链路追踪」的目标:一次请求的所有日志,都带上同一个
traceId
,排查问题时用 traceId
一搜,这条请求从进入、到查库、到返回的完整日志全部串起来。

实现三板斧:Filter 生成/透传 traceId → 放进 MDC → 日志
pattern 打印 %X{traceId}

package com.example.demo.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

/**
 * traceId 过滤器:为每个请求生成/透传 traceId,放进 MDC,回写响应头。
 * OncePerRequestFilter 保证同一请求只执行一次(避免转发时重复执行)。
 */
@Component
@Order(1)   // 越小越先执行,traceId 必须最早生成,后续日志才能带上
public class TraceIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        // 优先透传上游 traceId(网关/负载均衡传入),实现跨服务追踪;没有则自己生成
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        }
        // 放进 MDC:SLF4J 的线程本地上下文,之后该线程所有 log 都能打印出来
        MDC.put("traceId", traceId);
        // 回写响应头:让前端/调用方也能拿到,报错时带着 traceId 来找你
        response.setHeader("X-Trace-Id", traceId);
        try {
            filterChain.doFilter(request, response);
        } finally {
            // 关键:Tomcat 线程是复用的,必须清理,否则下一个请求会「串号」到上一个 traceId
            MDC.clear();
        }
    }
}

MDC 是什么MDC(Mapped Diagnostic
Context,映射诊断上下文)是 SLF4J 提供的一个 ThreadLocal
风格的键值容器。MDC.put("traceId", xxx)
之后,当前线程打印的所有日志里,只要 pattern 里写了
%X{traceId},就会自动带上这个值。因为它是「线程本地」的,所以多线程并发下各请求的
traceId 互不干扰——但也正因为如此,线程复用后必须
MDC.clear()
,否则会串号。

配套日志配置 src/main/resources/logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 控制台输出,pattern 里的 %X{traceId} 就是 MDC 中的 traceId -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

配好之后,一次请求的日志长这样(注意 [a1b2c3d4e5f67890]
是 traceId):

2026-08-14 10:00:00.123 [http-nio-8080-exec-1] [a1b2c3d4e5f67890] INFO  c.e.d.controller.UserController - 查询用户 id=1
2026-08-14 10:00:00.145 [http-nio-8080-exec-1] [a1b2c3d4e5f67890] DEBUG c.e.d.mapper.UserMapper - ==>  Preparing: SELECT ...

6.4 Interceptor
实战:登录态校验

登录态校验的生产链路是:登录接口发 token → 前端后续请求带上
Authorization 头 → 拦截器校验
token,失败抛未登录异常

第一步:登录接口 + TokenService

package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/** 登录入参 */
@Data
public class LoginDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;
}
package com.example.demo.service;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

import java.time.Duration;
import java.util.UUID;

/**
 * 令牌服务:登录签发 token,拦截器校验 token
 * 简化版:token  Redis,真实项目可替换为 JWT(阶段 5 安全篇详讲)。
 */
@Service
@RequiredArgsConstructor
public class TokenService {

    private final StringRedisTemplate redisTemplate;

    /** 签发 token:随机 UUID,存 Redis 并设置过期时间 */
    public String issue(Long userId) {
        String token = UUID.randomUUID().toString().replace("-", "");
        redisTemplate.opsForValue().set("token:" + token, String.valueOf(userId), Duration.ofHours(2));
        return token;
    }

    /** 校验 token:存在即视为有效 */
    public boolean verify(String token) {
        if (token == null || token.isBlank()) {
            return false;
        }
        return Boolean.TRUE.equals(redisTemplate.hasKey("token:" + token));
    }
}
package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.dto.LoginDTO;
import com.example.demo.service.TokenService;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final UserService userService;
    private final TokenService tokenService;

    @PostMapping("/login")
    public ApiResult<String> login(@Validated @RequestBody LoginDTO dto) {
        // 校验用户名密码(失败会抛 BizException,由全局异常处理器接管)
        Long userId = userService.login(dto);
        return ApiResult.ok(tokenService.issue(userId));
    }
}

第二步:AuthInterceptor

package com.example.demo.interceptor;

import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import com.example.demo.service.TokenService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * 登录态拦截器:校验 Authorization 头里的 token
 * 失败时抛 BizException(而非返回 false),让全局异常处理器返回统一的 401 格式。
 */
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 非 Controller 请求(如静态资源、错误页)直接放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        String token = request.getHeader("Authorization");
        if (!tokenService.verify(token)) {
            // 抛业务异常,而不是返回 false:这样能走全局异常处理,返回 ApiResult
            throw new BizException(ErrorCode.UNAUTHORIZED);
        }
        return true;
    }
}

为什么「抛异常」优于「返回 false」?preHandle 返回
false 只会中断请求,响应体是空的,前端拿到一个 200
空响应根本没法判断发生了什么;而抛
BizException(UNAUTHORIZED) 会被
GlobalExceptionHandler.handleBiz 接管,返回
{code: 40101, message: "未登录或登录已过期"} 的标准
JSON。

第三步:WebConfig 注册

package com.example.demo.config;

import com.example.demo.interceptor.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/api/**")          // 拦截所有 /api 接口
                .excludePathPatterns("/api/auth/login"); // 放行登录接口,否则永远登不上
    }
}

经典坑:excludePathPatterns
配错(少配登录路径、或路径拼错),会导致「登录接口也被拦截」,用户永远无法登录。注册后务必用
curl 验证登录接口能被匿名访问。

6.5 Listener 监听器

Listener
监听的是「事件」,而非「请求」。两类最常用:

  • ApplicationListener:监听 Spring
    容器事件,如
    ApplicationReadyEvent(容器就绪,可以开始干活)。
  • ServletContextListener:监听 Servlet
    容器的启动与销毁。
package com.example.demo.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

/**
 * 应用就绪监听器:容器启动完成、能处理请求时触发,适合做预热、初始化缓存。
 */
@Component
@Slf4j
public class AppStartupListener implements ApplicationListener<ApplicationReadyEvent> {

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        log.info("应用启动完成,可以开始处理请求");
    }
}

生产里 Listener
的典型用途:启动时预热缓存、加载字典表、注册服务到注册中心。它与
Filter/Interceptor
的定位完全不同——它不处理单个请求,只关心「容器的大事」。

6.6 Filter
的两种注册方式与执行顺序

Filter 有两种注册方式,各自适用场景不同:

  1. @Component +
    @Order
    :简单直接,适合无参、无特定拦截路径的
    Filter(如 TraceIdFilter)。@Order
    值越小越先执行。
@Component
@Order(1)
public class TraceIdFilter extends OncePerRequestFilter { ... }
  1. FilterRegistrationBean:需要指定 URL
    拦截模式、设置优先级、或控制「是否生效」时用。放在配置类里:
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<TraceIdFilter> traceIdFilterRegistration(TraceIdFilter filter) {
        FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(filter);
        registration.addUrlPatterns("/api/*");   // 只拦 /api
        registration.setOrder(1);               // 优先级,越小越先执行
        return registration;
    }
}

坑:用 @Component 注册的 Filter,Spring Boot
会默认把它挂到所有请求上;如果你同时又用
FilterRegistrationBean 注册了同一个
Filter,会导致同一个 Filter
执行两次
。所以二选一,别重复注册。

最后把四类「切点」的执行顺序钉死,这也是面试高频题:

Filter → Interceptor.preHandle → AOP(@Before) → 参数校验 → Controller 方法
→ AOP(@After/@Around) → Interceptor.postHandle → 视图渲染 → Interceptor.afterCompletion → Filter 返回

注意 AOP 的位置:拦截器包裹在 Controller 方法外层,而 AOP
是「方法级」的,@Around 环绕的是 Controller 方法本身,所以
AOP 在 preHandle
之后、方法体之前执行。这解释了为什么「限流」这类事放拦截器(要在方法执行前判断)比放
AOP 更合适——两者都能做,但职责层级不同。

本章小结Filter 在 Servlet
层做「进出门」的通用事(traceId、编码),Interceptor 在 MVC
层做「进 Controller 前」的鉴权校验,Listener
监听容器生命周期事件;登录校验用「抛
BizException」而非「返回 false」,保证走统一异常响应。


5. 生产级实战项目

第 7 章
生产级接口实战:完整「用户管理」项目

7.1 项目要串起什么

前六章讲了原理和单个组件,这一章把它们组装成一个能真正部署的「用户管理」服务。这个项目会完整串起本篇
80% 以上的知识点:

知识点 落点
DTO 入 / VO 出 / Entity 库 三层分离 全项目贯穿,Entity 不出接口
JSR-303 校验 + 分组 + 自定义注解 UserCreateDTO@Phone 自定义校验)
统一响应体 ApiResult 包装所有返回值
全局异常处理 BizException + GlobalExceptionHandler
登录态校验 AuthInterceptor + TokenService
traceId 全链路追踪 TraceIdFilter + MDC + logback
幂等防重 @Idempotent + AOP + Redis SETNX
限流 @RateLimit + RateLimitInterceptor + Redis
计数
日志脱敏 DesensitizeUtil
文件上传 FileController(大小/类型白名单/重命名)
跨域 CorsConfig

一次「创建用户」请求的完整流转(把前六章串成一条线):

POST /api/users  + Authorization: <token> + Body: {username, password, ...}
   │
   ▼ TraceIdFilter:生成 traceId 放进 MDC
   ▼ RateLimitInterceptor:@RateLimit 注解触发计数,超限抛 TOO_MANY_REQUESTS
   ▼ AuthInterceptor:校验 token,未登录抛 UNAUTHORIZED
   ▼ @Validated 校验 UserCreateDTO:@NotBlank/@Size/@Email/@Phone 失败抛 MethodArgumentNotValidException
   ▼ UserController.create() 调用 UserService.create()
   ▼ UserServiceImpl:业务校验(用户名唯一)+ 插入数据库 + log.info 脱敏日志
   ▼ 返回 ApiResult<Long>(自动带上 traceId)
   ▼ 异常路径:BizException/校验异常/兜底异常 → GlobalExceptionHandler → ApiResult

7.2
Entity:数据库实体(只对库,不出接口)

package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 用户实体:与数据库表一一对应。
 * 职责边界:它只出现在 Mapper  Service 内部,禁止直接作为接口入参或返回值。
 * 原因:实体含 password 等敏感字段,直接返回会泄露;直接接收请求则绕过校验。
 */
@Data
@TableName("user")
public class User {

    @TableId(type = IdType.AUTO)   // 主键自增
    private Long id;

    private String username;

    private String password;       // 生产应存 BCrypt 摘要,绝不存明文

    private String email;

    private String phone;

    private Integer age;

    private Integer status;        // 1=正常 0=禁用

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    @TableLogic                    // 逻辑删除:delete 变成 update deleted=1
    private Integer deleted;
}

7.3 Mapper:数据访问层

package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;

/**
 * 继承 BaseMapper 即获得 selectById/insert/updateById/deleteById/selectPage 等能力,
 * 无需写一行 SQL。复杂 SQL 再写 @Select  XML
 */
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

7.4 DTO:入参对象(带校验)

package com.example.demo.dto;

import com.example.demo.validator.Phone;
import jakarta.validation.constraints.*;
import lombok.Data;

/** 创建用户入参 */
@Data
public class UserCreateDTO {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3-20 之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 32, message = "密码长度必须在 6-32 之间")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Phone(message = "手机号格式不正确")   // 自定义校验注解(第 3 章)
    private String phone;

    @Min(value = 1, message = "年龄必须大于 0")
    @Max(value = 150, message = "年龄必须小于 150")
    private Integer age;
}
package com.example.demo.dto;

import com.example.demo.validator.Phone;
import jakarta.validation.constraints.*;
import lombok.Data;

/** 更新用户入参:用户名不可改,密码走独立流程,这里只更新可编辑字段 */
@Data
public class UserUpdateDTO {

    @Email(message = "邮箱格式不正确")
    private String email;

    @Phone(message = "手机号格式不正确")
    private String phone;

    @Min(value = 1, message = "年龄必须大于 0")
    @Max(value = 150, message = "年龄必须小于 150")
    private Integer age;

    @Min(value = 0, message = "状态值非法")
    @Max(value = 1, message = "状态值非法")
    private Integer status;
}
package com.example.demo.dto;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;

/**
 * 列表查询入参:GET 请求的查询串参数自动绑定到对象字段(@ModelAttribute 绑定)。
 */
@Data
public class UserQueryDTO {

    @Min(value = 1, message = "页码最小为 1")
    private int page = 1;      // 默认值:不传则用 1

    @Min(value = 1, message = "每页条数最小为 1")
    @Max(value = 100, message = "每页条数最大为 100")
    private int size = 10;     // 默认值:不传则用 10

    private String keyword;    // 按用户名模糊搜索
}

7.5
VO:出参对象(按需裁剪,绝不带密码)

package com.example.demo.vo;

import lombok.Data;

import java.time.LocalDateTime;

/**
 * 用户出参 VO:只返回前端需要的字段。
 *  Entity 的关键区别:没有 password 字段——这是三层分离的核心价值,
 * 从结构上杜绝「把密码返回给前端」这类事故。
 */
@Data
public class UserVO {
    private Long id;
    private String username;
    private String email;
    private String phone;
    private Integer age;
    private Integer status;
    private LocalDateTime createTime;
}

7.6 Service:接口 + 实现分离

package com.example.demo.service;

import com.example.demo.common.PageResult;
import com.example.demo.dto.*;
import com.example.demo.vo.UserVO;

public interface UserService {

    UserVO getById(Long id);

    PageResult<UserVO> listUsers(UserQueryDTO query);

    Long create(UserCreateDTO dto);

    void update(Long id, UserUpdateDTO dto);

    void delete(Long id);

    /** 登录校验:成功返回 userId,失败抛 BizException */
    Long login(LoginDTO dto);
}
package com.example.demo.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.common.ErrorCode;
import com.example.demo.common.PageResult;
import com.example.demo.dto.*;
import com.example.demo.entity.User;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.List;

@Service
@RequiredArgsConstructor   // 构造器注入:对 final 字段生成构造器,依赖不可变
@Slf4j
public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;

    @Override
    public UserVO getById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            // 业务错误用 BizException 抛出,交给全局异常处理器
            throw new BizException(ErrorCode.USER_NOT_FOUND);
        }
        return toVO(user);
    }

    @Override
    public PageResult<UserVO> listUsers(UserQueryDTO query) {
        // MyBatis-Plus 分页
        Page<User> page = new Page<>(query.getPage(), query.getSize());
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
                // like 第一个参数是条件开关:keyword 为空时该条件不生效
                .like(StringUtils.hasText(query.getKeyword()), User::getUsername, query.getKeyword())
                .orderByDesc(User::getCreateTime);
        Page<User> result = userMapper.selectPage(page, wrapper);
        List<UserVO> vos = result.getRecords().stream().map(this::toVO).toList();
        return PageResult.of(result.getTotal(), query.getPage(), query.getSize(), vos);
    }

    @Override
    public Long create(UserCreateDTO dto) {
        // 业务校验(格式校验已在前置拦截):用户名唯一
        Long count = userMapper.selectCount(
                new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername()));
        if (count > 0) {
            // 用 int+message 构造器演示临时业务码;正式项目建议加入 ErrorCode 枚举统一管理
            throw new BizException(40003, "用户名已存在");
        }

        User user = new User();
        user.setUsername(dto.getUsername());
        user.setPassword(dto.getPassword());  // 生产:先 BCrypt 加密再存,阶段 5 详讲
        user.setEmail(dto.getEmail());
        user.setPhone(dto.getPhone());
        user.setAge(dto.getAge());
        user.setStatus(1);
        userMapper.insert(user);

        // 脱敏日志:username 可打,但密码/手机号等敏感信息不落日志
        log.info("创建用户成功: id={}, username={}", user.getId(), user.getUsername());
        return user.getId();
    }

    @Override
    public void update(Long id, UserUpdateDTO dto) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new BizException(ErrorCode.USER_NOT_FOUND);
        }
        user.setEmail(dto.getEmail());
        user.setPhone(dto.getPhone());
        user.setAge(dto.getAge());
        if (dto.getStatus() != null) {
            user.setStatus(dto.getStatus());
        }
        userMapper.updateById(user);
        log.info("更新用户成功: id={}", id);
    }

    @Override
    public void delete(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new BizException(ErrorCode.USER_NOT_FOUND);
        }
        // 逻辑删除:@TableLogic 让 deleteById 变成 update deleted=1
        userMapper.deleteById(id);
        log.info("删除用户成功: id={}", id);
    }

    @Override
    public Long login(LoginDTO dto) {
        User user = userMapper.selectOne(
                new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername()));
        if (user == null || !user.getPassword().equals(dto.getPassword())) {
            // 统一用「用户名或密码错误」,不透露到底是哪个错(防撞库)
            throw new BizException(40102, "用户名或密码错误");
        }
        return user.getId();
    }

    /** Entity  VO:裁剪掉 password 等敏感字段 */
    private UserVO toVO(User user) {
        UserVO vo = new UserVO();
        vo.setId(user.getId());
        vo.setUsername(user.getUsername());
        vo.setEmail(user.getEmail());
        vo.setPhone(user.getPhone());
        vo.setAge(user.getAge());
        vo.setStatus(user.getStatus());
        vo.setCreateTime(user.getCreateTime());
        return vo;
    }
}

三层分离落地的关键在这一段:Controller 收
UserCreateDTO,Service 内部用 User
实体操作数据库,返回时 toVO 转成 UserVO
裁剪掉密码。DTO/VO/Entity 各管一段,谁都不越界。

7.7
Controller:接口层(只做参数接收与结果返回)

package com.example.demo.controller;

import com.example.demo.annotation.Idempotent;
import com.example.demo.annotation.RateLimit;
import com.example.demo.common.ApiResult;
import com.example.demo.common.PageResult;
import com.example.demo.dto.UserCreateDTO;
import com.example.demo.dto.UserQueryDTO;
import com.example.demo.dto.UserUpdateDTO;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public ApiResult<UserVO> getById(@PathVariable Long id) {
        log.info("查询用户: id={}", id);
        return ApiResult.ok(userService.getById(id));
    }

    @GetMapping
    public ApiResult<PageResult<UserVO>> list(@Validated UserQueryDTO query) {
        return ApiResult.ok(userService.listUsers(query));
    }

    @PostMapping
    @RateLimit(max = 10, windowSeconds = 60)   // 限流:单 IP 每分钟最多 10 次创建
    public ApiResult<Long> create(@Validated @RequestBody UserCreateDTO dto) {
        return ApiResult.ok(userService.create(dto));
    }

    @PutMapping("/{id}")
    public ApiResult<Void> update(@PathVariable Long id,
                                  @Validated @RequestBody UserUpdateDTO dto) {
        userService.update(id, dto);
        return ApiResult.ok(null);
    }

    @DeleteMapping("/{id}")
    @Idempotent(expire = 5)   // 幂等:5 秒内重复删除请求被拒绝
    public ApiResult<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ApiResult.ok(null);
    }
}

Controller
的两个「只」:只做参数接收(@PathVariable/@RequestBody/@Validated)和结果返回(ApiResult),业务逻辑全在
Service
。Controller 一旦出现 for 循环、if 判断业务、直接操作
Mapper,就说明分层破了。

7.8 幂等防重:@Idempotent +
AOP + Redis

「幂等」要解决的是:用户手抖双击、客户端超时重试,导致同一个操作被执行两次——轻则重复创建,重则重复扣款。核心思路:给每个请求生成一个唯一
key,窗口期内同样的 key 只放行一次

package com.example.demo.annotation;

import java.lang.annotation.*;

/**
 * 幂等注解:标注的方法在窗口期内只允许执行一次,重复提交抛 DUPLICATE_SUBMIT
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    /** 幂等窗口(秒),窗口内重复提交会被拒绝 */
    int expire() default 5;
}
package com.example.demo.aop;

import com.example.demo.annotation.Idempotent;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class IdempotentAspect {

    private final StringRedisTemplate redisTemplate;

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        String key = buildKey(pjp);
        // SETNX:key 不存在才设置成功(返回 true);已存在说明窗口期内重复提交
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "1", Duration.ofSeconds(idempotent.expire()));
        if (Boolean.FALSE.equals(success)) {
            log.warn("重复提交被拦截: key={}", key);
            throw new BizException(ErrorCode.DUPLICATE_SUBMIT);
        }
        // key 保留到过期,不主动删除:窗口期内的重复提交一律拒绝
        return pjp.proceed();
    }

    private String buildKey(ProceedingJoinPoint pjp) {
        String method = pjp.getSignature().toLongString();
        String args = Arrays.toString(pjp.getArgs());
        // 生产建议:key 应包含 userId + 业务单号;这里简化为「方法 + 参数」的 MD5
        String raw = method + ":" + args;
        return "idem:" + DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
    }
}

幂等的两种境界,别混:「防重复提交」(本示例)用「窗口期内同一
key
只放行一次」;「严格幂等」(如支付回调)要求「同一请求重复发
N 次,结果与发 1 次完全一致」,这需要业务侧配合(如订单号唯一索引 +
状态机),不是单纯 Redis 锁能解决的。生产里 POST
创建类接口通常还要在数据库加唯一索引兜底,双保险。

7.9 限流:@RateLimit
+ Interceptor + Redis 计数

限流防止接口被刷爆。用「固定窗口计数」实现:以「接口 + 客户端
IP」为维度,窗口内计数超过阈值就拒绝。

package com.example.demo.annotation;

import java.lang.annotation.*;

/**
 * 限流注解:标注的方法按固定窗口计数限流,超限抛 TOO_MANY_REQUESTS
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
    /** 窗口内允许的最大请求数 */
    int max() default 100;
    /** 窗口时长(秒) */
    int windowSeconds() default 60;
}
package com.example.demo.interceptor;

import com.example.demo.annotation.RateLimit;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import java.time.Duration;

@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitInterceptor implements HandlerInterceptor {

    private final StringRedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;   // 非 Controller 请求不限流
        }
        RateLimit rl = handlerMethod.getMethodAnnotation(RateLimit.class);
        if (rl == null) {
            return true;   // 未标注 @RateLimit 的方法不限流
        }
        // 限流维度:接口 + 客户端 IP(生产可换成 userId,维度越细越精确)
        String key = "rate:" + request.getRequestURI() + ":" + getClientIp(request);
        // 固定窗口:INCR 自增,首次时设置过期时间,过期后计数自动归零
        Long count = redisTemplate.opsForValue().increment(key);
        if (count != null && count == 1L) {
            redisTemplate.expire(key, Duration.ofSeconds(rl.windowSeconds()));
        }
        if (count != null && count > rl.max()) {
            log.warn("触发限流: key={}, count={}", key, count);
            throw new BizException(ErrorCode.TOO_MANY_REQUESTS);
        }
        return true;
    }

    private String getClientIp(HttpServletRequest request) {
        // 先取 X-Forwarded-For(经 Nginx/网关后的真实 IP),再回退到 remoteAddr
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

固定窗口有个边界问题:窗口交界处可能「双倍放行」(前 1 秒尾 + 后 1
秒头各计一次)。生产高要求场景用滑动窗口 /
令牌桶
,或直接上 Sentinel(阶段 7
详讲)。这里的固定窗口已能覆盖大多数「防刷」场景。

7.10 日志脱敏:DesensitizeUtil

日志是排查问题的生命线,但也是隐私泄露的高发地——手机号、身份证、密码一旦明文打进日志,就进了「所有能看日志的人」的视野。脱敏工具类:

package com.example.demo.common;

/**
 * 日志脱敏工具:敏感字段打码后再进日志。
 */
public class DesensitizeUtil {

    /** 手机号:138****1234 */
    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 7) {
            return phone;
        }
        return phone.replaceAll("(d{3})d{4}(d{4})", "$1****$2");
    }

    /** 身份证:前 4  4,中间打码 */
    public static String maskIdCard(String idCard) {
        if (idCard == null || idCard.length() < 8) {
            return idCard;
        }
        return idCard.replaceAll("(d{4})d+(d{4})", "$1**********$2");
    }

    /** 邮箱:前 2 位保留,如 zh***@example.com */
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) {
            return email;
        }
        String[] parts = email.split("@");
        String name = parts[0];
        String masked = name.length() <= 2
                ? name.charAt(0) + "*"
                : name.substring(0, 2) + "***";
        return masked + "@" + parts[1];
    }

    /** 密码:一律不打印内容 */
    public static String maskPassword() {
        return "******";
    }
}

使用示例(对照 UserServiceImpl 里的
log.info):

// 正确:脱敏后再打日志
log.info("用户 {} 登录成功", DesensitizeUtil.maskPhone(dto.getPhone()));

// 错误:明文打日志,隐私泄露
log.info("用户 {} 登录,密码 {}", dto.getUsername(), dto.getPassword());  // 禁止!

7.11
文件上传:FileController(生产校验三件套)

文件上传是安全重灾区:不校验类型能传
.jsp/.sh
脚本,不校验大小能把磁盘打爆,不重命名能路径穿越覆盖系统文件。生产三件套:大小限制
+ 类型白名单 + 随机重命名

package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.Set;
import java.util.UUID;

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
@Slf4j
public class FileController {

    // 类型白名单:只允许这几种,其余一律拒绝
    private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
    private static final long MAX_SIZE = 5 * 1024 * 1024;  // 5MB

    @PostMapping("/upload")
    public ApiResult<String> upload(@RequestParam("file") MultipartFile file) {
        // 1. 非空校验
        if (file == null || file.isEmpty()) {
            throw new BizException(ErrorCode.FILE_UPLOAD_ERROR.getCode(), "文件不能为空");
        }
        // 2. 大小校验
        if (file.getSize() > MAX_SIZE) {
            throw new BizException(ErrorCode.FILE_UPLOAD_ERROR.getCode(), "文件大小不能超过 5MB");
        }
        // 3. 扩展名白名单校验(防上传恶意脚本)
        String original = file.getOriginalFilename();
        String ext = (original != null && original.contains("."))
                ? original.substring(original.lastIndexOf('.') + 1).toLowerCase()
                : "";
        if (!ALLOWED_EXT.contains(ext)) {
            throw new BizException(ErrorCode.FILE_UPLOAD_ERROR.getCode(), "不支持的文件类型: " + ext);
        }
        // 4. 随机重命名:UUID 防路径穿越、防覆盖同名文件
        String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
        try {
            File dir = new File("upload");
            if (!dir.exists() && !dir.mkdirs()) {
                throw new BizException(ErrorCode.FILE_UPLOAD_ERROR);
            }
            // 生产建议:存对象存储 OSS/MinIO,而不是应用本地磁盘
            file.transferTo(new File(dir, newName).getAbsoluteFile());
        } catch (IOException e) {
            log.error("文件保存失败: {}", original, e);
            throw new BizException(ErrorCode.FILE_UPLOAD_ERROR);
        }
        log.info("文件上传成功: {} -> {}", original, newName);
        return ApiResult.ok(newName);
    }
}

配套的 multipart 配置(application.yml 追加):

spring:
  servlet:
    multipart:
      max-file-size: 10MB       # 单文件上限(比业务校验 5MB 宽,业务校验兜底更友好)
      max-request-size: 10MB    # 整个请求上限

注意「两层大小限制」:max-file-size
是框架层硬限制,超了直接抛
MaxUploadSizeExceededException(可加到全局异常处理器);业务里再校验
5MB 是软限制,能给用户更友好的提示。生产建议两者都配。

7.12 跨域:CorsConfig

前后端分离时,前端页面(如
https://www.example.com)调后端接口(https://api.example.com),浏览器会因「同源策略」拦截跨域请求,需要后端返回
CORS 头声明允许。

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://www.example.com")   // 生产明确来源,禁用 *(配合 allowCredentials 时 * 非法)
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)                      // 允许携带 Cookie
                .maxAge(3600);                               // 预检请求缓存 1 小时
    }
}

两个坑:① allowCredentials(true)
allowedOrigins 不能是
*(规范不允许),必须写明确域名;②
生产环境跨域通常由网关(Nginx/Spring Cloud
Gateway)统一处理,应用层配置只是本地开发兜底。

7.13 完整目录树(最终成果)

demo-web
├── pom.xml
└── src/main
    ├── java/com/example/demo
    │   ├── DemoApplication.java
    │   ├── controller
    │   │   ├── AuthController.java
    │   │   ├── FileController.java
    │   │   └── UserController.java
    │   ├── service
    │   │   ├── UserService.java
    │   │   ├── TokenService.java
    │   │   └── impl/UserServiceImpl.java
    │   ├── mapper/UserMapper.java
    │   ├── entity/User.java
    │   ├── dto
    │   │   ├── UserCreateDTO.java
    │   │   ├── UserUpdateDTO.java
    │   │   ├── UserQueryDTO.java
    │   │   └── LoginDTO.java
    │   ├── vo/UserVO.java
    │   ├── config
    │   │   ├── WebConfig.java
    │   │   ├── CorsConfig.java
    │   │   └── AppStartupListener.java
    │   ├── exception
    │   │   ├── BizException.java
    │   │   └── GlobalExceptionHandler.java
    │   ├── interceptor
    │   │   ├── AuthInterceptor.java
    │   │   └── RateLimitInterceptor.java
    │   ├── filter/TraceIdFilter.java
    │   ├── annotation
    │   │   ├── Idempotent.java
    │   │   └── RateLimit.java
    │   ├── aop/IdempotentAspect.java
    │   ├── validator
    │   │   ├── Phone.java
    │   │   └── PhoneValidator.java
    │   └── common
    │       ├── ApiResult.java
    │       ├── ErrorCode.java
    │       ├── PageResult.java
    │       └── DesensitizeUtil.java
    └── resources
        ├── application.yml
        └── logback-spring.xml

7.14 运行与验证

# 1. 启动(确保 MySQL、Redis 已按第 3 章启动)
mvn spring-boot:run

# 2. 登录拿 token
curl -X POST http://localhost:8080/api/auth/login 
  -H "Content-Type: application/json" 
  -d '{"username":"admin","password":"123456"}'
# 预期返回(若用户存在):{"code":0,"message":"success","data":"<token>","traceId":"..."}

# 3. 用 token 创建用户
curl -X POST http://localhost:8080/api/users 
  -H "Content-Type: application/json" 
  -H "Authorization: <token>" 
  -d '{"username":"zhangsan","password":"abc12345","email":"z@example.com","phone":"13812341234","age":25}'
# 预期:{"code":0,"message":"success","data":1,"traceId":"..."}

# 4. 参数校验失败(密码太短)
curl -X POST http://localhost:8080/api/users 
  -H "Content-Type: application/json" 
  -H "Authorization: <token>" 
  -d '{"username":"zs","password":"123","phone":"abc"}'
# 预期:{"code":40001,"message":"用户名长度必须在 3-20 之间; 密码长度必须在 6-32 之间; 手机号格式不正确","traceId":"..."}

# 5. 未登录访问
curl -X GET http://localhost:8080/api/users/1
# 预期:{"code":40101,"message":"未登录或登录已过期","traceId":"..."}

# 6. 查不存在的用户(已登录)
curl -X GET http://localhost:8080/api/users/99999 -H "Authorization: <token>"
# 预期:{"code":40401,"message":"用户不存在","traceId":"..."}

# 7. 连续快速调用删除接口,第二次触发幂等拒绝
curl -X DELETE http://localhost:8080/api/users/1 -H "Authorization: <token>" &
curl -X DELETE http://localhost:8080/api/users/1 -H "Authorization: <token>"
# 第二次预期:{"code":40901,"message":"请勿重复提交","traceId":"..."}

观察点:每个响应都带 traceId,到控制台日志里搜这个
traceId,能看到这次请求从进入 Filter 到返回的完整日志链路。

本章小结:一个生产级接口 =
三层分离(DTO/VO/Entity)+ 参数校验 + 统一响应 + 全局异常 + 登录拦截 +
traceId + 幂等 + 限流 + 脱敏 + 上传校验 +
CORS。这些能力环环相扣,缺一环都会在线上暴露问题。


6. 常见坑与排错指南

坑 / 现象 原因 解决方案
校验注解写了,但完全不生效 Controller 参数漏写 @Validated /
@Valid
入参前加 @Validated;嵌套集合字段加 @Valid
级联
接口返回 500 的 HTML 错误页而非 JSON 异常没被全局处理器兜住,冒泡到容器 确认 GlobalExceptionHandler
@RestControllerAdvice + Exception.class
兜底
兜底异常把 e.getMessage() 直接返回前端 泄露 SQL、类名、敏感信息 兜底只返回 ErrorCode.SYSTEM_ERROR,堆栈
log.error 进日志
Entity 直接接收请求或返回响应 泄露 password 等敏感字段、绕过校验 严格 DTO 入 / VO 出 / Entity 库,转换在 Service 层
@RequestParam 必填参数没传报裸 400 Spring 抛 MissingServletRequestParameterException
required=false/defaultValue,或在全局异常处理器补
handler
拦截器把登录接口也拦了 excludePathPatterns 漏配或路径拼错 注册时 excludePathPatterns("/api/auth/login"),用 curl
验证
traceId 串号(A 请求的日志带了 B 的 traceId) 线程复用后 MDC 没清理 TraceIdFilterfinally 里必须
MDC.clear()
日志打印明文密码/手机号/身份证 隐私泄露,违规风险 DesensitizeUtil 脱敏后再打日志
上传不校验类型/大小 传恶意脚本、磁盘被打爆 大小限制 + 扩展名白名单 + 随机重命名
@Transactional 失效 同类内自调用绕过了代理 方法移到另一个 Bean,或用 AopContext 拿代理(详见阶段
4)
404 处理写了 NoHandlerFoundException 不生效 Spring Boot 3.2 改抛 NoResourceFoundException 改用 NoResourceFoundException
allowCredentials(true) 时跨域配置不生效 allowedOrigins("*")
allowCredentials(true) 冲突
allowedOrigins 写明确域名,禁用 *
从网上抄 javax.* 的 import 编译不过 Spring Boot 3.x 已迁移到 jakarta.* 全部替换为
jakarta.servlet.*jakarta.validation.*
@Validated 分组校验时字段校验「丢失」 没标 groups 的注解属于 Default 组,与指定组不匹配 把每个注解的 groups 写清楚,或用
@Validated({Create.class, Default.class})

排错三板斧(任何接口问题先按这个顺序来):

  1. 看 traceId 串日志:响应里的 traceId
    拿去日志里 grep,一次请求的全链路日志立刻现形。
  2. 看异常落在哪一层:是
    MethodArgumentNotValidException(校验没拦住)还是
    BizException(业务抛错)还是兜底 Exception(有
    bug)——日志里 warn/error 级别能直接区分。
  3. 看 Handler 是否匹配:加
    logging.level.org.springframework.web=debug,能打印出每个请求最终匹配到哪个
    Handler、经过了哪些拦截器。

三个真实排错案例(都是生产里真会遇到的):

  • 案例一:接口突然开始返回
    {"code":40101,...},但用户明明登录了。排查:AuthInterceptor
    校验的 Authorization 头,前端传的是
    Bearer <token>,后端 TokenService.verify
    拿整串去 Redis 查 key,当然查不到。教训:token
    的「取出—裁剪—校验」协议要和前端对齐
    ,要么后端
    substring(7) 去掉 Bearer
    前缀,要么约定前端不传前缀。

  • 案例二:本地能跑,上了 Nginx 后 traceId
    全是同一个值。排查:Nginx 没透传 X-Trace-Id
    请求头,导致后端「透传不到上游」后自己生成——但多个请求被 Nginx
    复用连接时,若 Nginx 把上游响应的 X-Trace-Id
    当成了下一个请求的请求头(配置了错误的
    proxy_set_header),就会出现串号。教训:traceId
    的透传链路要端到端核对
    ,Filter
    里也要保证「生成逻辑」在「透传为空」时才执行。

  • 案例三POST /api/users
    限流阈值设了 max=10,结果第 3 次请求就被拒。排查:限流 key
    用了 request.getRemoteAddr(),但所有请求都经同一台 Nginx
    反代,remoteAddr 永远是 Nginx 的
    IP,等于全站共享一个计数器。教训:有反代/网关时必须用
    X-Forwarded-For 取真实
    IP
    ,否则限流维度全错。


8. 总结与延伸阅读

这一篇把「写一个能跑的接口」推进到了「写一个能上生产的接口」。你掌握的核心是一根链条
+ 四个地基 + 一摞生产能力:一根链条是请求从 Filter 到
Controller 再返回的完整流转顺序;四个地基
ApiResultErrorCodeBizExceptionGlobalExceptionHandler,它们让所有接口的成功与失败都走统一格式;一摞生产能力是参数校验、登录拦截、traceId、幂等、限流、脱敏、文件上传、跨域。记住最核心的一条原则:接口的输入永远不可信,异常永远要有兜底,日志永远可追踪,敏感信息永不落地——这四条做到了,你的接口就从「能用」跨到了「敢上线」。

延伸阅读(按推荐顺序):

  1. Spring 官方文档 · Spring MVC 章节https://docs.spring.io/spring-framework/reference/web/webmvc.html)——DispatcherServlet、拦截器、异常解析的第一手资料。
  2. 《Spring 实战》(第 6 版)Web 部分——系统性的 Spring
    MVC 全景,适合建立完整框架感。
  3. 《阿里巴巴 Java 开发手册》(嵩山版)·
    接口设计规约
    ——国内生产环境的接口命名、返回结构、异常处理实战规范。
  4. Jakarta Bean Validation 规范https://jakarta.ee/specifications/bean-validation/)——深入理解
    JSR-303 校验机制与自定义约束。
  5. 阶段 4《数据访问》(本系列下一篇)——本篇 User
    实体的 MyBatis-Plus 用法、事务、@Transactional
    失效问题将在那里展开。

本文档为「Spring Boot 教程系列」阶段 3,与阶段 1/2/4
共同构成一条从容器、自动配置到 Web、数据访问的完整学习路径。

正文完
 0
评论(没有评论)