本篇是"前端工程师转后端"系列第 3 篇。第 1 篇我们建立了工程地图,第 2 篇我们理解了 Java、Maven、Spring Boot 如何把项目启动成一个后端服务。从这一篇开始,我们正式进入"接口开发"本身:一个浏览器或 curl 发出的 HTTP 请求,如何被 Spring Boot 找到对应 Controller?URL 上的路径参数、query 参数、JSON body 又如何变成 Java 对象?后端为什么要统一返回
ApiResponse<T>,为什么不能把数据库 Entity 直接丢给前端?
说明:本文仍然基于当前fullstack-mall后端工程。文中前端代码均为"前端侧示意代码",只用于类比 Axios、表单和 TypeScript interface,不代表仓库存在真实前端源码。
1. 这篇解决什么问题
前端同学开始学习后端时,通常最容易理解的是"接口"。因为你每天都在写类似这样的代码:
ts
// 前端侧示意代码:当前仓库没有真实前端源码
const res = await axios.get('/api/products', {
params: {
pageNum: 1,
pageSize: 10,
keyword: '手机'
}
})
或者:
ts
// 前端侧示意代码:当前仓库没有真实前端源码
const res = await axios.post('/api/admin/products', {
categoryId: 1,
title: '新商品',
description: '商品描述'
})
你知道前端会发出 HTTP 请求,也知道浏览器 Network 面板里能看到 URL、method、query string、request payload、response JSON。但当你打开后端代码时,看到的是:
java
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
...
}
}
对前端来说,这里有很多新概念:@RestController、@RequestMapping、@GetMapping、@PathVariable、@RequestBody、@Valid、ResponseEntity、ApiResponse<T>。如果你只是死记注解,很快会混乱:什么时候用 query 参数?什么时候用 body?为什么有些接口返回 ResponseEntity<ApiResponse<...>>,有些直接返回 ApiResponse<...>?校验失败为什么没有进入业务方法,却仍然返回了统一 JSON?
本篇解决以下问题:
- Spring Boot 如何把 HTTP 请求路由到 Controller 方法;
@RestController和@RequestMapping在当前项目中承担什么职责;- GET 查询接口如何接收 query 参数;
- 路径里的
{id}如何绑定到 Java 的Long id; - POST / PATCH 接口如何接收 JSON body;
- Request DTO、Response DTO、Entity 的边界如何理解;
@Valid和 Bean Validation 如何把参数错误变成 400;ApiResponse<T>为什么是给前端看的统一响应信封;GlobalExceptionHandler如何把异常转换成稳定响应;- 如何用 curl 验证成功、参数错误、路径不存在三类结果。
学完本篇,你应该能拿着一个接口地址,反向定位到后端 Controller 方法;也能从一个 Controller 方法,推导出前端应该怎么传参、会收到什么响应结构。
2. 用前端知识类比:从 Axios 到 Controller
前端调用接口时,通常关注三件事:请求地址、请求参数、响应数据。后端 Controller 也是围绕这三件事展开,只是站在"接收方"的角度。
| 前端视角 | 后端视角 | 当前项目例子 |
|---|---|---|
axios.get('/api/products') |
@GetMapping 接收 GET 请求 |
ProductController.queryProducts(...) |
params: { pageNum: 1 } |
query 参数绑定到 Request DTO | ProductQueryRequest.pageNum |
URL 里的 /api/products/1 |
@PathVariable Long id |
ProductController.detail(...) |
axios.post(url, body) |
@RequestBody 读取 JSON body |
ProductAdminController.createProduct(...) |
| TypeScript interface | Java Request / Response DTO | ProductCreateRequest、ProductDetailResponse |
| Axios response interceptor | 后端统一响应信封 | ApiResponse<T> |
| 表单校验错误展示 | Bean Validation 字段错误 | ValidationErrorData.fieldErrors |
| Network 里看 status code | ResponseEntity 设置 HTTP 状态码 |
创建商品返回 201 Created |
前端发请求,后端接请求。你可以把 Controller 想象成"服务端路由组件"。但它和 Vue Router 不完全一样。Vue Router 负责把浏览器 URL 映射到页面组件;Spring Controller 负责把 HTTP 请求映射到 Java 方法。页面组件通常返回 DOM 或组件树,Controller 方法通常返回 Java 对象,最后由 Spring Boot 自动序列化成 JSON。
一个接口的完整请求链路可以画成这样:
对前端同学来说,最重要的思维转换是:以前你只关心"我要怎么调接口",现在你要关心"别人调我的接口时,我如何定义稳定契约"。契约包括地址、method、参数位置、字段类型、校验规则、响应结构、错误码、HTTP 状态码。后端一旦对外提供接口,就要尽量保证这些契约稳定,因为前端、测试、文档、调用方都会依赖它。
3. 后端核心概念讲解
3.1 Controller 是什么
Controller 是 Web 层入口。当前项目里,公开商品接口的入口是:
text
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java
管理员商品接口的入口是:
text
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductAdminController.java
Controller 的职责不是"写所有业务逻辑",而是处理 HTTP 相关的事情:
- 定义 URL 前缀和具体路径;
- 指定 GET、POST、PATCH 等 HTTP method;
- 接收 query、path、body 参数;
- 触发参数校验;
- 调用 Facade 或 Service 完成业务;
- 把业务结果包装成统一响应;
- 必要时设置 HTTP 状态码。
它更像前端里的 API route 或 BFF 层入口,而不是一个"万能业务类"。如果 Controller 里堆满数据库查询、库存扣减、事务判断、缓存处理,后续会非常难维护。当前项目采用的是 Controller 调用 IProductFacade,再由 Facade 组织后续业务,这个边界适合初学者理解。
3.2 @RestController:告诉 Spring 这是 JSON 接口入口
当前 ProductController 的类上有:
java
@RestController
@RequestMapping("/api/products")
@Tag(name = "公开商品")
public class ProductController {
...
}
@RestController 可以先理解成两层含义:
- 这个类会被 Spring 扫描并创建为 Bean;
- 这个类里的方法返回值会作为响应体,通常序列化成 JSON。
早期 Spring MVC 里常见 @Controller 返回页面模板,而 @RestController 更适合 REST API。对于当前项目,接口都是给前端、curl 或 Swagger 调用的 JSON API,所以 Controller 类使用 @RestController。
前端类比:如果 Vue 组件负责渲染页面,那么 Controller 不是"页面组件",而是"API handler"。它不返回 HTML,而是返回可序列化的数据对象。
3.3 @RequestMapping 与具体 Mapping:类级路径 + 方法级路径
当前公开商品 Controller 类级路径是:
java
@RequestMapping("/api/products")
方法上又有:
java
@GetMapping
以及:
java
@GetMapping("/{id}")
最终组合出来两个接口:
text
GET /api/products
GET /api/products/{id}
这和前端路由的"父路由 + 子路由"很像。比如你可以把 /api/products 看作父路径,空子路径表示列表,/{id} 表示详情。管理员商品 Controller 的类级路径是 /api/admin/products,所以它下面的 @PostMapping 对应:
text
POST /api/admin/products
它下面的 @PatchMapping("/{id}/status") 对应:
text
PATCH /api/admin/products/{id}/status
它下面的 @PatchMapping("/{id}/subtitle") 对应:
text
PATCH /api/admin/products/{id}/subtitle
路径组合规则可以画成这样:
初学时一定要养成"类级路径和方法级路径一起看"的习惯。很多 404 都不是业务代码错,而是你只看了方法上的 @GetMapping,忘记加类上的 @RequestMapping。
3.4 Query 参数:GET 列表查询如何绑定 Request DTO
公开商品分页查询方法是:
java
@GetMapping
@Operation(summary = "分页查询已上架商品")
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
PageResponse<ProductSummaryResponse> response = productFacade.queryPublishedProducts(request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这个方法接收的是 GET 请求。GET 请求通常不使用 JSON body,而是把筛选条件放在 URL query string 里,例如:
bash
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10&keyword=phone&categoryId=1'
Spring 会尝试把这些 query 参数绑定到 ProductQueryRequest 对象上。这个类在 contract 模块中:
java
public class ProductQueryRequest {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String keyword;
private Long categoryId;
private ProductStatus status;
}
这和前端 TypeScript interface 很像:
ts
// 前端侧示意代码
interface ProductQueryRequest {
pageNum?: number
pageSize?: number
keyword?: string
categoryId?: number
status?: 'DRAFT' | 'ON_SALE' | 'OFF_SALE'
}
但 Java DTO 还有一个前端 interface 没有的能力:它可以携带服务端校验规则。比如 pageNum 上有 @Min(value = 1),pageSize 上有 @Max(value = 100),categoryId 上有 @Positive。当前 Controller 参数前面加了 @Valid,意味着绑定完成后会触发校验。如果你请求:
bash
curl 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
后端会认为参数不合法,抛出校验异常,并由统一异常处理返回 400。注意,这种错误在进入业务查询前就能被拦住。它的价值和前端表单校验相似,但更可靠,因为服务端不能相信前端一定做了校验。
3.5 Path 参数:详情接口里的 {id} 如何变成 Long id
商品详情方法是:
java
@GetMapping("/{id}")
@Operation(summary = "查询已上架商品详情")
public ApiResponse<ProductDetailResponse> detail(
@PathVariable Long id,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.getPublishedProduct(id);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这里的 /{id} 表示路径中有一段动态变量。请求:
text
GET /api/products/1001
会把 1001 绑定到方法参数 Long id。前端类比就是 Vue Router 里的动态路由:
ts
// 前端侧示意代码
{
path: '/products/:id',
component: ProductDetailPage
}
在前端页面里你可能写 route.params.id,在后端里就是 @PathVariable Long id。区别在于:前端拿到的通常先是字符串,后端会尝试把它转换成 Long。如果路径里传的是 /api/products/abc,类型转换会失败,后端不会把 abc 当成合法商品 ID。
路径参数适合表示"资源标识"。比如商品详情、订单详情、修改某个商品状态,路径里放 id 很自然。query 参数适合表示筛选条件、分页条件、排序条件。JSON body 适合表示复杂提交数据。不要把所有参数都塞进 query,也不要把所有操作都做成 POST body;接口可读性本身就是后端设计的一部分。
3.6 JSON body:POST / PATCH 如何接收复杂对象
管理员创建商品接口是:
java
@PostMapping
@Operation(summary = "创建商品草稿")
public ResponseEntity<ApiResponse<ProductDetailResponse>> createProduct(
@Valid @RequestBody ProductCreateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
这里有两个关键注解:@RequestBody 和 @Valid。@RequestBody 表示从 HTTP request body 中读取 JSON,并转换成 Java 对象。@Valid 表示转换成功后继续校验字段规则。
前端侧请求大概是:
ts
// 前端侧示意代码
await axios.post('/api/admin/products', {
categoryId: 1,
title: '新商品',
description: '这是一段商品描述'
})
后端 ProductCreateRequest 定义了允许提交的字段:
java
public class ProductCreateRequest {
@NotNull(message = "商品分类不能为空")
@Positive(message = "商品分类 ID 必须大于 0")
private Long categoryId;
@NotBlank(message = "商品标题不能为空")
@Size(max = 100, message = "商品标题不能超过 100 个字符")
private String title;
@Size(max = 2000, message = "商品描述不能超过 2000 个字符")
private String description;
}
这段代码体现了后端接口设计中非常重要的思想:前端能传什么,必须由服务端白名单定义。 创建商品时,前端只能提交分类、标题、描述。它不能提交商品 ID,因为 ID 应由数据库生成;不能提交创建人 ID,因为创建人应该来自登录上下文;不能随意提交创建时间,因为时间应该由服务端生成;也不能直接提交状态流转结果,因为状态变更应该走专门接口和业务规则。
这和前端表单模型很像,但后端要求更严格。前端表单可以为了展示方便多放一些字段,后端 Request DTO 必须只包含当前操作允许外部输入的字段。否则就会出现越权、脏数据或安全漏洞。
3.7 Request DTO、Response DTO、Entity 的边界
很多前端同学刚写后端时会问:为什么不能直接让 Controller 接收数据库 Entity?为什么不能直接返回 Entity?原因是 Request、Response、Entity 代表三个不同边界。
Request DTO 面向"外部输入"。它回答:这个接口允许调用方提交哪些字段?这些字段有什么校验规则?哪些字段必须由服务端自己生成,绝不能让前端传?
Response DTO 面向"外部输出"。它回答:这个接口承诺返回哪些字段?哪些字段是给页面展示的?哪些字段应该隐藏?字段名和结构如何保持稳定?当前项目的 ProductDetailResponse 包含 id、categoryId、categoryName、title、subtitle、description、status、createdBy、createdAt、updatedAt 等字段。它是给前端看商品详情用的契约。
Entity 面向"数据库表"。它回答:表里有哪些字段?Java 类型如何映射数据库类型?字段名如何从下划线转成驼峰?Entity 的变化常常和数据库结构有关,而 Request / Response 的变化和 API 契约有关。二者不应该强行混在一起。
前端类比:你不会把后端接口原始响应直接当成页面所有内部状态,也不会把组件内部临时字段原样提交给接口。你会区分 form model、view model、API payload。后端也一样,只是边界更严格,因为后端还要保护数据库、安全和业务规则。
3.8 ApiResponse<T>:统一响应信封
当前项目所有业务 API 共用响应信封:
java
public class ApiResponse<T> {
private String code;
private String message;
private T data;
private String traceId;
private Instant timestamp;
}
成功时使用:
java
ApiResponse.success(response, TraceIdContext.get(servletRequest))
错误时使用:
java
ApiResponse.error(code, message, data, traceId)
前端拿到响应后,不应该只看 HTTP 状态码,也不应该解析中文 message。更稳定的方式是看 code。因为 message 是给人看的文案,未来产品可能会调整;code 是给程序判断的稳定业务码,例如 SUCCESS、VALIDATION_ERROR、PRODUCT_NOT_FOUND、UNAUTHORIZED、FORBIDDEN。
统一响应信封的好处有很多:
- 前端响应拦截器可以统一处理
code; - 错误结构稳定,表单错误、业务错误、系统错误都有固定位置;
traceId可以帮助前后端联调,前端报错时把 traceId 给后端,后端能在日志中定位;timestamp能说明响应生成时间,方便排查缓存、时区、重试问题;- 泛型
T让不同接口的data保持类型清晰。
前端侧可以这样理解:
ts
// 前端侧示意代码
interface ApiResponse<T> {
code: string
message: string
data: T
traceId: string
timestamp: string
}
如果接口返回商品详情,那么 T 就是 ProductDetailResponse;如果返回分页商品列表,那么 T 就是 PageResponse<ProductSummaryResponse>;如果返回字段校验错误,那么 T 就是 ValidationErrorData。这就是 Java 泛型和 TypeScript 泛型非常相似的地方。
3.9 ResponseEntity:为什么创建商品返回 201
多数查询接口直接返回 ApiResponse<T>,Spring Boot 会默认使用 200 状态码。但创建商品接口返回的是:
java
ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)))
HttpStatus.CREATED 对应 HTTP 201。它表达的语义是:服务端已经创建了一个新资源。前端调用成功后,不仅能从响应体里拿到商品详情,也能从 HTTP 状态码知道这是一次创建成功。
前端同学以前可能只关心 res.data,但做后端后要更重视 HTTP 语义。常见状态码可以先这样理解:
| 状态码 | 含义 | 当前项目中的典型场景 |
|---|---|---|
| 200 | 请求成功 | 查询列表、查询详情、修改状态成功 |
| 201 | 创建成功 | 创建商品草稿成功 |
| 400 | 请求参数或 JSON 格式错误 | @Valid 校验失败、JSON 语法错误 |
| 401 | 未登录或登录过期 | 后续 JWT / Security 章节会讲 |
| 403 | 已登录但无权限 | 管理接口权限不足 |
| 404 | 路径或资源不存在 | 路径不存在,或业务资源不存在时也可能使用类似业务码 |
| 500 | 未预期服务端错误 | 兜底异常处理 |
HTTP 状态码是协议层语义,ApiResponse.code 是业务层语义。两者不是互相替代,而是配合使用。
3.10 @Valid 与统一异常处理
当前项目的参数校验不是散落在每个 Controller 里手写 if。它使用 Jakarta Validation 注解,例如:
java
@NotBlank(message = "商品标题不能为空")
@Size(max = 100, message = "商品标题不能超过 100 个字符")
private String title;
Controller 参数上加 @Valid 后,Spring 在调用业务方法前会自动校验。如果校验失败,会抛出 MethodArgumentNotValidException。这个异常由:
text
backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java
统一处理。handleValidation(...) 会把字段错误转换成 ValidationErrorData,并返回 400。这样前端能拿到结构化字段错误,而不是一段不可解析的异常堆栈。
这和前端表单校验有相似之处:前端可能用 rules 校验 title 必填、长度不超过 100;后端也用注解校验。但二者目的不同。前端校验是为了用户体验,减少无效请求;后端校验是安全边界,保证无论调用方是谁,都不能绕过规则。
参数校验和异常处理链路可以画成这样:
这张图解释了一个常见现象:有时候你在 Controller 方法第一行打断点,但请求没有进来。原因可能不是路由没匹配,而是 JSON 解析或参数校验在进入方法前就失败了。
4. 在本项目中对应哪些文件
本章主要阅读这些文件:
| 文件 | 本章关注点 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java |
公开商品列表和详情接口,学习 GET、query、path 参数 |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductAdminController.java |
管理员创建、修改状态、修改副标题、分页查询接口,学习 POST、PATCH、body、201 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductQueryRequest.java |
GET 查询参数 DTO 和字段校验 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductCreateRequest.java |
创建商品 body DTO 和字段校验 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductDetailResponse.java |
商品详情 Response DTO |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiResponse.java |
所有业务 API 的统一响应信封 |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiCode.java |
稳定业务码枚举,前端应该优先判断 code |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/PageResponse.java |
分页响应结构 |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ValidationErrorData.java |
参数校验错误响应数据 |
backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java |
统一异常处理,把 Java 异常转成 HTTP 状态码和 ApiResponse |
docs/backend-basics/backend-foundation/02-spring-boot-and-web.md |
既有后端基础文档,可对照理解 Spring MVC、Controller、Bean |
本章不深入数据库、Redis、登录权限。即使管理员接口后续会涉及 Spring Security,我们这里只关注 Controller 如何定义接口形状。权限机制会在 JWT 与 Spring Security 章节展开。
5. Mermaid 图:一个接口从匹配到响应
把本章所有概念合在一起,可以得到一个"请求处理总图":
这张图适合你以后排查所有接口问题。先判断路径是否匹配,再判断参数是否能绑定,再判断校验是否通过,再看业务逻辑是否成功,最后看响应包装是否符合预期。
6. 逐段读源码
6.1 读 ProductController 的类定义
核心代码是:
java
@RestController
@RequestMapping("/api/products")
@Tag(name = "公开商品")
public class ProductController {
@Resource
private IProductFacade productFacade;
}
@RestController 表示这是 REST API Controller。@RequestMapping("/api/products") 定义这一组接口的公共前缀。@Tag 是 OpenAPI / Swagger 文档用的标签,不影响业务逻辑,但会影响接口文档展示分组。
@Resource private IProductFacade productFacade; 表示 Controller 不自己创建业务对象,而是让 Spring 注入一个实现了 IProductFacade 的 Bean。前端类比:你不会在每个组件里重新实现一遍请求库,而是 import 一个封装好的 service;后端也不会在 Controller 里直接写全部业务,而是调用 Facade。
这里还有一个设计细节:字段类型是接口 IProductFacade,不是具体实现 ProductFacade。这体现了面向接口编程。Controller 只依赖"商品业务能力",不直接依赖"商品业务如何实现"。后续如果实现内部引入缓存、事务、MyBatis-Plus,Controller 的接口形状不必跟着变化。
6.2 读公开列表接口
java
@GetMapping
@Operation(summary = "分页查询已上架商品")
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
PageResponse<ProductSummaryResponse> response = productFacade.queryPublishedProducts(request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这个方法至少包含 6 个信息点。
第一,@GetMapping 没有额外路径,所以完整路径就是类级路径 /api/products。请求方法必须是 GET。
第二,ProductQueryRequest request 是查询条件对象。因为它没有 @RequestBody,所以这里不是从 JSON body 读取,而是绑定 query 参数。@ParameterObject 主要服务于 springdoc,让 Swagger 能把对象字段展示成 query 参数。
第三,@Valid 触发字段校验。只要 pageNum 小于 1,或者 pageSize 大于 100,就会触发校验错误。
第四,HttpServletRequest servletRequest 不是业务参数,而是底层 HTTP 请求对象。当前项目用它从 TraceIdContext 里取 traceId,再放到统一响应中。
第五,Controller 调用 productFacade.queryPublishedProducts(request)。方法名里的 Published 很关键:公开商品列表只能查询已上架商品。即使 ProductQueryRequest 里有 status 字段,公开查询也不应该让前端随意查草稿或下架商品。这个约束通常会在 Facade 或 DbService 里完成。
第六,返回类型是 ApiResponse<PageResponse<ProductSummaryResponse>>。从外到内读:最外层统一响应信封;data 是分页对象;分页对象里的 records 是商品摘要列表。
前端侧可以推导出响应类型大概是:
ts
// 前端侧示意代码
type ProductListResponse = ApiResponse<PageResponse<ProductSummaryResponse>>
6.3 读商品详情接口
java
@GetMapping("/{id}")
@Operation(summary = "查询已上架商品详情")
public ApiResponse<ProductDetailResponse> detail(
@PathVariable Long id,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.getPublishedProduct(id);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这个方法比列表接口更简单,但它展示了 path 参数的绑定。@GetMapping("/{id}") 中的 {id} 和 @PathVariable Long id 对应。请求 /api/products/1 时,Spring 会把路径段 1 转成 Long。
这里同样调用的是 getPublishedProduct(id),而不是"随便查任意商品"。公开详情接口也只应该返回已上架商品。这一点对前端很重要:如果后台管理系统能看到草稿商品,不代表用户端商品详情页也能看到。后端接口通常按使用场景拆分,而不是一个接口满足所有页面。
返回的 ProductDetailResponse 是商品详情 DTO。它包含 categoryName,这说明响应并不只是商品表字段的简单复制,而可能经过了跨表组装。也包含 createdAt、updatedAt,这些字段来自服务端和数据库,不应该由前端传入。
6.4 读管理员创建接口
java
@PostMapping
@Operation(summary = "创建商品草稿")
public ResponseEntity<ApiResponse<ProductDetailResponse>> createProduct(
@Valid @RequestBody ProductCreateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
这个接口的完整路径是 POST /api/admin/products。它和公开商品列表的路径不同,前缀多了 /admin,表示这是管理端能力。后续安全章节会讲为什么管理端接口需要认证和权限控制。
@RequestBody 是这里最重要的注解。它告诉 Spring:请求体是 JSON,要把 JSON 字段映射到 ProductCreateRequest。如果请求体不是合法 JSON,例如少了引号或多了逗号,会触发 HttpMessageNotReadableException,被全局异常处理转换成 MALFORMED_JSON。
ProductCreateRequest 的字段非常克制:只有 categoryId、title、description。这是一种白名单设计。前端提交创建商品表单时,可能页面上还有临时图片预览、富文本编辑状态、loading、dirty 标记,但这些都不应该进入后端 Request DTO。后端只接收业务需要且允许外部输入的字段。
这个方法返回 ResponseEntity<ApiResponse<ProductDetailResponse>>,是为了设置 HTTP 201。相比直接返回 ApiResponse,ResponseEntity 给了 Controller 更细粒度的 HTTP 响应控制能力。你以后遇到需要设置状态码、响应头、文件下载时,也会见到它。
6.5 读管理员 PATCH 接口
管理员商品 Controller 中还有两个 PATCH 接口:
java
@PatchMapping("/{id}/status")
public ApiResponse<ProductDetailResponse> changeStatus(
@PathVariable Long id,
@Valid @RequestBody ProductStatusChangeRequest request,
HttpServletRequest servletRequest
) { ... }
以及:
java
@PatchMapping("/{id}/subtitle")
public ApiResponse<ProductDetailResponse> updateSubtitle(
@PathVariable Long id,
@Valid @RequestBody ProductSubtitleUpdateRequest request,
HttpServletRequest servletRequest
) { ... }
PATCH 通常表示对资源做局部修改。这里路径里的 id 指定"修改哪一个商品",body 里指定"修改成什么"。把 id 放路径、把修改内容放 body,是一种清晰的 REST 风格。
前端侧示意:
ts
// 前端侧示意代码
await axios.patch(`/api/admin/products/${id}/subtitle`, {
subtitle: '新的副标题'
})
注意:虽然修改状态和修改副标题都返回 ProductDetailResponse,但它们的 Request DTO 不应该混用。状态变更有状态机规则,副标题修改有字段长度和空值规则。每个操作使用专门 DTO,可以让校验和业务意图更清楚。
6.6 读 ProductQueryRequest
ProductQueryRequest 是典型查询 DTO。它包含默认值:
java
private Integer pageNum = 1;
private Integer pageSize = 10;
这意味着前端不传分页参数时,服务端仍然有默认分页行为。不要让列表接口默认返回所有数据,这是后端分页设计的基本原则。前端无限滚动、分页表格、搜索列表,都应该和后端分页契约配合。
字段上的校验注解表达了服务端底线:页码不能小于 1,每页数量不能小于 1,也不能大于 100;关键字长度不能超过 100;分类 ID 必须大于 0。这些限制不是为了为难前端,而是为了保护数据库和服务稳定性。没有分页上限的接口,很容易被一次请求拖垮。
这里还有一个 status 字段,注释写明"管理端可用,公开查询会由服务端强制为 ON_SALE,不能被前端覆盖"。这体现了一个重要原则:同一个 DTO 可以在不同场景复用,但权限和业务规则必须由后端兜底。公开接口即使收到 status=DRAFT,也不应该让匿名用户查到草稿商品。
6.7 读 ProductCreateRequest
创建商品请求 DTO 体现了"输入白名单 + 字段校验":
java
@NotNull(message = "商品分类不能为空")
@Positive(message = "商品分类 ID 必须大于 0")
private Long categoryId;
@NotBlank(message = "商品标题不能为空")
@Size(max = 100, message = "商品标题不能超过 100 个字符")
private String title;
@Size(max = 2000, message = "商品描述不能超过 2000 个字符")
private String description;
@NotNull 表示不能为 null,但字符串如果是空白还需要 @NotBlank。@Positive 表示数字必须大于 0。@Size 可以限制字符串长度,也可以限制集合长度。初学时不要纠结每个注解的所有参数,先记住它们是"服务端表单规则"。
前端做表单时也会写必填和长度限制。但前端校验只是第一道体验层,后端校验才是最终可信层。任何人都可以绕过你的页面,用 curl、Postman、脚本直接请求接口。如果后端不校验,就相当于把数据库安全交给调用方自觉。
6.8 读 ProductDetailResponse
商品详情响应 DTO 有这些特点:
- 有数据库主键
id; - 有分类 ID 和分类名称;
- 有标题、副标题、描述;
- 有商品状态;
- 有创建人和创建、更新时间。
它是"对外展示模型",不是数据库 Entity。比如 categoryName 很可能来自分类表,不一定是商品表字段。Response DTO 可以根据页面需要组合多个来源的数据。前端拿到它以后,可以直接用于详情页展示。
但 Response DTO 也要克制。不要为了"以后可能用到"把所有内部字段都返回给前端。返回越多,契约越重,泄露风险越高,后续改动越困难。比如成本价、内部审核备注、删除标记、版本号等字段,如果前端页面不需要,就不应该随便出现在公开响应里。
6.9 读 ApiResponse
ApiResponse<T> 的字段注释已经写得很清楚:code 是稳定业务码,message 是面向人的提示,data 是成功数据或结构化错误详情,traceId 是链路标识,timestamp 是服务端响应时间。
这里要特别强调 traceId。前端报错时,经常只能说"接口报错了"。如果响应里带 traceId,前端可以把它复制给后端,后端通过日志查找对应请求。这是前后端联调效率提升非常明显的设计。以后你做全栈开发,也应该养成给错误、日志、响应建立关联 ID 的习惯。
timestamp 使用 Instant,这是一个 UTC 时间点。前端展示时可能需要转换成本地时区。不要在后端和前端之间传模糊时间字符串,否则很容易出现时区问题。后续订单、支付、超时关单都会涉及时间,提前建立时间意识很重要。
6.10 读 GlobalExceptionHandler
GlobalExceptionHandler 类上有:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
...
}
@RestControllerAdvice 可以理解成"全局 Controller 异常拦截器"。它不会处理正常业务成功路径,而是处理 Controller 或参数绑定过程中抛出的异常。
当前它处理了几类错误:
MethodArgumentNotValidException:@Valid校验失败;HttpMessageNotReadableException:JSON 格式错误、字段类型不匹配、枚举转换失败;BusinessException:可预期业务失败;NoResourceFoundException:请求路径不存在;Exception:兜底未知异常。
这和前端 Axios response interceptor 很像。前端拦截器把后端响应统一转成弹窗、跳登录、错误提示;后端全局异常处理器把 Java 异常统一转成 HTTP 状态码和 ApiResponse。双方都在做一件事:让错误处理集中、稳定、可维护。
最重要的是最后的兜底异常处理。它会把详细堆栈写到服务端日志,但对外只返回通用 INTERNAL_ERROR。这是安全设计。不要把数据库 SQL、堆栈路径、内部类名全部返回给前端用户。前端需要的是错误提示和 traceId,后端日志才保存详细排查信息。
7. 本地怎么运行 / curl 怎么验证
7.1 启动服务
如果你还没有准备 MySQL 和 Redis,可以先用第 2 篇讲过的 local profile:
bash
cd backend
mvn -pl service -am spring-boot:run -Dspring-boot.run.profiles=local
如果你使用真实开发依赖:
bash
docker compose -f docker-compose.dev.yml up -d mysql redis
cd backend
mvn -pl service -am spring-boot:run
下面的 curl 以默认端口 8080 为例。如果你使用 debug profile,请把端口改成 8081。
7.2 验证公开商品列表 GET query
bash
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10'
你应该关注响应的形状,而不是具体商品数量:
json
{
"code": "SUCCESS",
"message": "操作成功",
"data": {
"pageNum": 1,
"pageSize": 10,
"total": 0,
"pages": 0,
"records": []
},
"traceId": "...",
"timestamp": "..."
}
不同环境下种子数据可能不同,所以 total 和 records 不一定完全一样。你要验证的是:路径命中、参数绑定成功、返回统一信封、分页结构存在。
7.3 验证商品详情 path 参数
bash
curl 'http://localhost:8080/api/products/1'
如果商品存在且已上架,你会得到 SUCCESS 和商品详情。如果商品不存在或未上架,可能得到业务错误码。这里不要求你马上理解查询数据库过程,只要知道 1 会绑定到 Controller 的 @PathVariable Long id。
你也可以故意传一个非数字:
bash
curl 'http://localhost:8080/api/products/abc'
这类请求通常会在参数转换阶段失败。它能帮助你理解:进入 Controller 方法前,Spring 已经做了路径匹配、类型转换、参数绑定等工作。
7.4 验证 query 参数校验失败
bash
curl 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
预期应返回 HTTP 400,并且响应体是统一结构,code 类似 VALIDATION_ERROR,data.fieldErrors 中包含字段错误。前端可以根据 fieldErrors 把错误展示到分页组件、搜索表单或全局提示里。
这个验证非常适合前端转后端同学练习。你会发现:后端参数校验不是写在业务方法里的 if,而是在 Controller 方法执行前由框架和全局异常处理完成。
7.5 验证创建商品 body 校验失败
管理员接口可能需要登录权限,后续安全章节会讲如何携带 JWT。即使你当前没有登录,也可以先理解请求形状:
bash
curl -i -X POST 'http://localhost:8080/api/admin/products' \
-H 'Content-Type: application/json' \
-d '{"categoryId": 1, "title": "", "description": "demo"}'
如果请求能进入参数校验阶段,空标题会触发 商品标题不能为空。如果你没有登录,可能先被安全过滤器拦截,返回 401 或 403。这正好说明后端请求链路不只有 Controller:安全过滤器、参数绑定、校验、业务逻辑都有自己的先后顺序。
7.6 验证 JSON 格式错误
bash
curl -i -X POST 'http://localhost:8080/api/admin/products' \
-H 'Content-Type: application/json' \
-d '{"categoryId": 1, "title": "demo",}'
末尾多了一个逗号,不是合法 JSON。它会触发 JSON 解析错误,对应 GlobalExceptionHandler.handleMalformedJson(...)。前端同学可以把它理解成:请求还没形成合法 Java 对象,所以不会进入业务方法。
7.7 验证路径不存在
bash
curl -i 'http://localhost:8080/api/not-exists'
预期会返回 404,并由统一异常处理包装成 ApiResponse。这比 Spring 默认错误页更适合前后端分离项目,因为前端可以用统一逻辑处理错误,而不是解析一段 HTML 或不稳定 JSON。
8. 常见错误
错误 1:只看方法路径,忘记类级路径
看到 @GetMapping("/{id}") 就请求 /1,当然会 404。完整路径必须把类上的 @RequestMapping("/api/products") 加上,所以应该是 /api/products/1。
错误 2:GET 请求里错误使用 JSON body
列表查询通常应该用 query 参数。你如果用 GET body,很多客户端、代理、网关并不会按你预期处理。当前 ProductQueryRequest 是 query 参数绑定,不是 @RequestBody。
错误 3:POST body 忘记 Content-Type: application/json
如果请求体是 JSON,但没有正确设置 Content-Type,后端可能无法按 JSON 解析。curl 验证 POST 时建议显式加:
bash
-H 'Content-Type: application/json'
错误 4:把 Request DTO 当 Entity 使用
创建商品只允许传 categoryId、title、description,不代表商品表只有这些字段。Request DTO 是外部输入白名单,Entity 是数据库映射,Response DTO 是外部输出契约。三者不能混成一个类。
错误 5:前端做了校验,就以后端不用校验
前端校验只是用户体验。任何人都可以绕过页面直接调用接口。后端必须校验必填、长度、数值范围、枚举值、权限和业务状态。
错误 6:解析 message 做业务判断
当前项目明确把 code 作为稳定业务码,message 是面向人的提示。前端应该根据 code 判断跳转、弹窗、表单错误,而不是匹配中文文案。
错误 7:所有错误都返回 200
有些项目会把所有错误都塞进响应体,HTTP 状态码永远 200。这样会降低调试和监控质量。当前项目对参数错误返回 400,对路径不存在返回 404,对未知错误返回 500,对创建成功返回 201,更符合 HTTP 语义。
错误 8:Controller 写太多业务逻辑
Controller 应该薄。它负责 HTTP 契约和调用业务入口,不应该堆复杂库存、事务、缓存和 SQL。当前项目用 IProductFacade 承接业务入口,后续章节会继续向下读 Facade、DbService、Mapper。
错误 9:不知道错误发生在 Controller 前还是 Controller 后
如果 JSON 解析失败、类型转换失败、@Valid 校验失败,请求可能根本不会进入 Controller 方法。调试时不要只在业务方法里打断点,也要看 HTTP 状态码、响应体、全局异常处理日志。
错误 10:公开接口和管理接口混用
/api/products 是匿名用户可访问的公开商品入口,/api/admin/products 是管理员商品管理入口。它们路径不同、权限不同、业务约束不同。不要因为都操作商品,就把它们合成一个接口。
9. 本章小练习
练习 1:从 URL 反推 Controller 方法
给出请求:
text
GET /api/products/12
请回答:
- 命中哪个 Controller 类?
- 命中哪个方法?
12会绑定到哪个 Java 参数?- 返回的
data类型是什么?
参考方向:看 ProductController 的类级 @RequestMapping 和方法级 @GetMapping("/{id}")。
练习 2:从 Controller 方法推导 curl
看到方法:
java
@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) { ... }
请写出一个带 pageNum、pageSize、keyword 的 curl 请求。注意它是 GET query,不是 JSON body。
练习 3:找出创建商品允许前端提交的字段
打开:
text
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductCreateRequest.java
回答:
- 哪些字段必填?
- 哪些字段有长度限制?
- 为什么不能让前端传
id、createdBy、createdAt?
练习 4:观察参数校验错误结构
启动服务后请求:
bash
curl -i 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
记录:
- HTTP 状态码是多少?
- 响应里的
code是什么? fieldErrors里有哪些字段?traceId是否存在?
练习 5:画出创建商品请求链路
用你自己的话画出:
text
POST /api/admin/products
从 JSON body 到 ProductCreateRequest,再到 productFacade.createProduct(request),最后到 ApiResponse<ProductDetailResponse> 的链路。可以用 Mermaid,也可以手画。
练习 6:区分三种模型
请把下面字段分到 Request DTO、Response DTO、Entity 或"不应该由前端提交":
title;description;categoryName;createdBy;createdAt;id;subtitle;deleted;version。
这个练习没有唯一答案,因为要看具体接口场景。但你的判断必须能解释"这个字段由谁产生、谁消费、是否可信"。
10. 本篇总结
这一篇我们真正进入了后端接口层。你现在应该知道:
- Controller 是 HTTP 请求进入业务系统的 Web 层入口;
@RestController让方法返回值作为 JSON 响应体;- 类级
@RequestMapping和方法级@GetMapping、@PostMapping、@PatchMapping共同组成完整路径; - GET 列表查询通常用 query 参数,并可以绑定到
ProductQueryRequest; - URL 动态段用
@PathVariable绑定,例如/api/products/{id}; - POST / PATCH 的复杂提交数据通常用
@RequestBody绑定 JSON; @Valid会触发 Request DTO 上的 Bean Validation 校验;- Request DTO 是输入白名单,Response DTO 是输出契约,Entity 是数据库映射;
ApiResponse<T>统一了code、message、data、traceId、timestamp;ResponseEntity可以控制 HTTP 状态码,例如创建成功返回 201;GlobalExceptionHandler把校验错误、JSON 错误、业务错误、404、未知异常转换成统一响应。
对前端工程师来说,这一章最重要的变化是:你不再只是"调用接口的人",而是开始成为"定义接口契约的人"。一个好的后端接口,不只是能跑通,还要让路径、method、参数位置、校验规则、响应结构、错误码都稳定、清晰、可调试。
11. 下一章预告
下一篇是第 4 篇:MySQL 与 SQL 基础:从前端状态到服务端持久化数据。
我们会先补你缺的数据库基础,再回到当前项目的 sql/01_schema.sql,循序渐进讲:
- 数据库、表、行、列到底是什么;
- 主键、外键、唯一约束、索引分别解决什么问题;
- 为什么后端不能只把数据放内存里;
- 商品、分类、SKU、订单这些表大概如何关联;
created_at、updated_at、状态字段为什么重要;- 前端状态、localStorage、后端 MySQL 持久化之间的区别;
- 如何用最基础 SQL 查询当前项目的数据。
学完下一篇,你就能把"接口返回 JSON"继续向下追到"数据来自哪张表、字段是什么意思、为什么要这么设计"。