版本: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%
的线上问题——参数没校验、异常没兜住、接口被重复提交、排查日志时找不到同一请求的上下文——都出在这一条链路上。
这一篇,你会完整走通这条链路:
- 看懂请求流程:一个请求从进入
Filter到
DispatcherServlet,再到
Interceptor、参数校验、Controller,最后渲染返回,每一步由谁负责、在什么时候触发。 - 设计规范的 REST API:URL 怎么起、HTTP
方法语义怎么用、参数怎么绑。 - 建立三层分离:DTO 入参、VO 出参、Entity
入库,为什么必须分开,以及不分开会出什么事故。 - 搭好统一底座:统一响应体
ApiResult、错误码ErrorCode、业务异常
BizException、全局异常处理器
GlobalExceptionHandler,这四个类是你后续所有接口的公共地基,从本篇起全系列统一复用。 - 补齐生产级能力:幂等防重、traceId
全链路追踪、日志脱敏、限流、文件上传、跨域。
学完这一篇,你能独立交付一个分层清晰、校验齐全、异常可控、可追踪的「用户管理」REST
服务,这也是后面数据访问、安全、微服务等所有阶段的地基——它们都建立在你写出的这些接口之上。
2. 学习目标与前置要求
学完你能…
- 画出一次请求从
Filter进入、经
Interceptor、参数校验到Controller
再返回的完整执行链路,并说清每一步的触发时机(面试高频)。 - 设计出语义规范的 RESTful API,正确使用
@RequestMapping
家族、@PathVariable/@RequestParam/
@RequestBody绑定参数。 - 用 JSR-303 注解 + 分组校验 + 自定义校验注解,把非法数据挡在
Controller 之外。 - 手写
ApiResult/ErrorCode/
BizException/
GlobalExceptionHandler,做到「对外模糊提示、堆栈只进日志」。 - 区分
Filter/Interceptor/ 监听器 / AOP
的职责边界,并实现登录态拦截器 + traceId Filter。 - 交付一个覆盖幂等、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 | 全系列统一,record、sealed等新特性可用 |
| 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。
这个环节有两个高频实际问题:
-
LocalDateTime
被序列化成数组:Jackson 默认把LocalDateTime写成
[2026,8,14,10,0,0]这种数组,前端没法用。解决:字段加
@JsonFormat(详见第 3 章 3.8)。 -
循环引用导致序列化栈溢出:实体间双向关联(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」这种「动作命名」风格?
- 语义自解释:
GET /api/users/1
一看就知道是查 id=1 的用户,不需要文档。 - 与 HTTP 协议对齐:HTTP 本身就定义了
GET/POST/PUT/DELETE 的语义,REST
只是把业务「挂」到这个标准协议上,能免费享受缓存、幂等、中间件等 HTTP
生态能力。 - 前后端分离友好:URL 稳定、返回
JSON,前端、App、第三方都能消费同一套接口。
2.2 HTTP
方法语义(用错是生产事故)
| 方法 | 语义 | 是否幂等 | 是否安全 | 典型场景 |
|---|---|---|---|---|
GET |
查询资源 | 是 | 是 | 查单个、查列表 |
POST |
创建资源 | 否 | 否 | 新增用户、下单 |
PUT |
全量更新资源 | 是 | 否 | 更新整个对象 |
PATCH |
部分更新 | 否 | 否 | 只改一个字段 |
DELETE |
删除资源 | 是 | 否 | 删除用户 |
幂等:同一个请求重复发多次,结果与发一次相同。
GET、PUT、DELETE
天然幂等;POST不幂等(重复 POST
会创建多条)。「是否安全」指是否会改变服务端状态,GET
是安全的。生产事故案例:把「扣款」这类操作写成
GET,结果被浏览器预加载、搜索引擎爬虫反复触发,重复扣款。凡是改变状态的操作,绝不能用
GET。
2.3 URL 设计规则
- 资源用名词复数,不用动词:
/api/users(对),/api/getUsers(错)。 - 层级表达从属关系:
/api/users/1/orders(用户的订单)。 - 用路径参数定位资源,用查询参数做筛选/分页:
/api/users/1(定位)+
/api/users?page=1&size=10(筛选)。 - 版本号放 URL 或请求头:
/api/v1/users
或Accept: application/vnd.demo.v1+json。 - 统一小写 +
短横线:/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 = false或defaultValue可以避免「裸
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
从头用到尾」,这是生产事故的头号来源。三个对象必须分开,各有各的职责:
| 对象 | 职责 | 关键特征 | 禁止行为 |
|---|---|---|---|
Entity(User) |
数据库映射 | 带 MyBatis-Plus 注解,含 password、deleted等内部字段 |
禁止接请求、禁止返回响应 |
DTO(UserCreateDTO) |
入参对象 | 带 JSR-303 校验注解,只含「前端能提交」的字段 | 禁止带数据库注解 |
VO(UserVO) |
出参对象 | 按需裁剪,剔除敏感字段(如 password) |
禁止带数据库注解 |
不分开会出什么具体事故:
- 用 Entity 返回响应:
User有
password字段,JSON
序列化时把密码明文回给前端——这是安全事件,不是风格问题。 - 用 Entity 接请求:前端传一个
{"deleted": 0, "id": 1}
就能改逻辑删除标记甚至主键,造成「批量绑定」漏洞(Mass
Assignment)。 - 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)校验。它的价值有三层:
- 前置拦截:非法数据根本进不了业务方法,Service
层不需要写防御性if。 - 声明式:校验规则以注解形式写在 DTO
字段上,规则和字段「贴在一起」,可读性极强。 - 错误信息友好:校验失败抛出
MethodArgumentNotValidException,由全局异常处理器转成统一格式返回(第
5 章)。
关键认知:校验只防「格式错误」,不防「业务错误」。
能保证字符串长得像邮箱,但保证不了「这个邮箱没被注册过」——后者是 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不行。@NotEmpty:null和""
不行,但" "(纯空白)能通过。@NotBlank:null、""、" "
都不能通过。校验用户名/密码这种「必须填内容」的字段,一律用
@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/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 的序列化/反序列化,用于@RequestBodyDTO 和
@ResponseBodyVO 的日期字段。
@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% 的场景,但有两种情况需要了解「底层怎么跑」:
- 编程式校验:不依赖注解,手动调用
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);
}
}
}
- 快速失败(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
套解析逻辑,联调成本爆炸,出问题时也难定位。
统一响应体解决三个问题:
- 结构统一:所有接口返回
{code, message, data, traceId},前端只需一套解析逻辑。 - 错误表达统一:成功和失败走同一个结构,只是
code和message不同,前端能统一弹提示。 - 可追溯:
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两个方法里,调用方不可能漏设
code或message。 traceId取自 MDC:MDC是
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,前端解析直接崩
统一异常处理要达成的目标:
- 收口:所有异常在一个地方处理,Controller
保持干净。 - 统一格式:异常也返回
ApiResult,前端错误处理逻辑和成功路径一致。 - 分层记录:可预期异常记
warn,不可预期异常记error并带完整堆栈。 - 防泄露:对外只给模糊提示,内部细节(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
生态里有四样东西都能「在请求处理过程中插入一段逻辑」:Filter、Interceptor、Listener、AOP。它们的区别是面试高频题,也是生产里用错重灾区。核心区分维度是层级和能否拿到
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 返回
三个关键结论,务必记住:
preHandle返回false
会中断后续流程:后面的 Interceptor、Controller
都不会执行。这是「拦截」的实现方式。但生产里更常用「抛
BizException
中断」,因为抛异常能被全局异常处理器接管,返回统一格式,而返回
false只会得到一个空响应。afterCompletion一定执行:即使
preHandle抛异常、Controller 抛异常,它也在
finally语义下被调用,适合做资源清理和耗时统计。- 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 有两种注册方式,各自适用场景不同:
@Component+
@Order:简单直接,适合无参、无特定拦截路径的
Filter(如TraceIdFilter)。@Order
值越小越先执行。
@Component
@Order(1)
public class TraceIdFilter extends OncePerRequestFilter { ... }
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 没清理 | TraceIdFilter 的 finally 里必须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}) |
排错三板斧(任何接口问题先按这个顺序来):
- 看 traceId 串日志:响应里的
traceId
拿去日志里 grep,一次请求的全链路日志立刻现形。 - 看异常落在哪一层:是
MethodArgumentNotValidException(校验没拦住)还是
BizException(业务抛错)还是兜底Exception(有
bug)——日志里warn/error级别能直接区分。 - 看 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 再返回的完整流转顺序;四个地基是
ApiResult、ErrorCode、BizException、GlobalExceptionHandler,它们让所有接口的成功与失败都走统一格式;一摞生产能力是参数校验、登录拦截、traceId、幂等、限流、脱敏、文件上传、跨域。记住最核心的一条原则:接口的输入永远不可信,异常永远要有兜底,日志永远可追踪,敏感信息永不落地——这四条做到了,你的接口就从「能用」跨到了「敢上线」。
延伸阅读(按推荐顺序):
- Spring 官方文档 · Spring MVC 章节(https://docs.spring.io/spring-framework/reference/web/webmvc.html)——DispatcherServlet、拦截器、异常解析的第一手资料。
- 《Spring 实战》(第 6 版)Web 部分——系统性的 Spring
MVC 全景,适合建立完整框架感。 - 《阿里巴巴 Java 开发手册》(嵩山版)·
接口设计规约——国内生产环境的接口命名、返回结构、异常处理实战规范。 - Jakarta Bean Validation 规范(https://jakarta.ee/specifications/bean-validation/)——深入理解
JSR-303 校验机制与自定义约束。 - 阶段 4《数据访问》(本系列下一篇)——本篇 User
实体的 MyBatis-Plus 用法、事务、@Transactional
失效问题将在那里展开。
本文档为「Spring Boot 教程系列」阶段 3,与阶段 1/2/4
共同构成一条从容器、自动配置到 Web、数据访问的完整学习路径。