Spring MVC 接口设计教程:Controller、参数绑定与统一响应
前言
上一章把订单接口跑通后,项目已经有了一个可用的 Controller。但当接口逐渐增加,真正的难点不再是“怎么返回 JSON”,而是每个参数应该从哪里接收、一次修改应该提交多少字段、发生错误时调用方能否稳定处理。
本章会继续使用订单项目,完成四个接口:
- 创建订单。
- 根据订单号查询。
- 按客户编号查询订单列表。
- 使用
PATCH更新订单金额。
完成后,你能根据 HTTP 语义选择路径变量、查询参数和请求体,也能解释为什么 Controller 不应该承载订单业务。
配套源码:dvsusan/spring-boot4-demo。
最终结果
本章结束时,调用下面这个接口:
curl "http://localhost:8080/api/v1/orders?customerId=C1001&limit=20"会得到统一结构的订单列表:
{
"code": "0",
"message": "success",
"data": [
{
"orderNo": "SOD7D5107CB2AA",
"customerId": "C1001",
"amount": 99.00,
"status": "CREATED"
}
],
"timestamp": "2026-11-06T05:03:32.918609Z"
}订单号和时间戳会变化,结构不会变化。
请求从进入 MVC 到返回响应的路径如下:

图 1:Controller 只完成协议适配;参数通过校验后才进入订单业务。
第 1 步:先写清楚接口契约
接口契约是服务端和调用方共同遵守的约定。动手写注解前,先把本章接口列出来:
| 方法 | 路径 | 参数位置 | 成功状态 |
|---|---|---|---|
POST | /api/v1/orders | JSON 请求体 | 201 Created |
GET | /api/v1/orders/{orderNo} | 路径变量 | 200 OK |
GET | /api/v1/orders | 查询参数 | 200 OK |
PATCH | /api/v1/orders/{orderNo} | 路径变量 + JSON 请求体 | 200 OK |
这里有两个容易混用的方法。
PUT 表示用请求内容整体替换资源,PATCH 表示只提交需要修改的部分。本章的更新接口只修改金额,客户编号可以省略,所以使用 PATCH 更贴合语义。
路径中也保留了 /v1。本章先把它当成稳定的 URL 版本入口,下一章会单独比较 URL、Header 和媒体类型三种版本管理方案。
第 2 步:让业务服务支持查询与更新
打开 demo-order/src/main/java/com/susan/training/order/OrderService.java。前面已经存在 create 和 findByOrderNo,现在增加两个方法:
/**
* 按客户编号分页查询,limit 由 Web 层校验并限制上限。
*/
public List<OrderView> findByCustomerId(String customerId, int limit) {
return orders.values()
.stream()
.filter(order -> order.customerId().equals(customerId))
.sorted((left, right) -> left.orderNo().compareTo(right.orderNo()))
.limit(limit)
.toList();
}
/**
* 更新订单金额;customerId 为空时保留原客户,避免 PATCH 请求覆盖未提交字段。
*/
public OrderView update(
String orderNo,
String customerId,
java.math.BigDecimal amount
) {
OrderView current = findByOrderNo(orderNo);
String nextCustomerId = customerId == null || customerId.isBlank()
? current.customerId()
: customerId;
OrderView updated = new OrderView(
current.orderNo(),
nextCustomerId,
amount,
current.status()
);
orders.put(orderNo, updated);
return updated;
}findByCustomerId 仍然位于业务模块,没有使用 @RequestParam 或任何 Web 注解。以后这部分代码被定时任务或消息消费者复用时,也不会被 HTTP 类型绑死。
这里的 limit 是教学版数量上限,不等同于完整分页。真实项目还要返回游标、总数或下一页标识。
第 3 步:用 Controller 适配 HTTP
订单接口位于:
demo-web/src/main/java/com/susan/training/web/OrderController.java类级映射提供统一前缀:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
// 构造器注入让必需依赖在对象创建时就确定。
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
}订单 Controller 的四个方法分别对应不同参数来源。下面的代码聚焦本章接口;第 14 篇会再给 create 和 update 增加实时事件发布。
3.1 请求体:创建订单
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<OrderView> create(
@RequestBody CreateOrderRequest request
) {
// 先把 HTTP 请求对象转换成 Web 无关的业务命令。
OrderView order = orderService.create(
new CreateOrderCommand(request.customerId(), request.amount())
);
return ApiResponse.success(order);
}@RequestBody 表示 JSON 从请求体读取。@ResponseStatus(HttpStatus.CREATED) 让创建成功的状态码为 201,而不是所有接口都返回 200。
3.2 路径变量:查询单个订单
@GetMapping("/{orderNo}")
public ApiResponse<OrderView> find(@PathVariable String orderNo) {
// 路径中的订单号直接作为业务查询条件。
return ApiResponse.success(orderService.findByOrderNo(orderNo));
}请求:
curl "http://localhost:8080/api/v1/orders/SO685E417C3C4F"{orderNo} 是路径模板,@PathVariable 负责把它绑定到方法参数。订单号是资源标识,适合放在路径里。
3.3 查询参数:按客户搜索
@GetMapping
public ApiResponse<List<OrderView>> findByCustomer(
@RequestParam String customerId,
@RequestParam(defaultValue = "20") int limit
) {
// 查询条件不是资源身份,因此使用查询参数。
return ApiResponse.success(orderService.findByCustomerId(customerId, limit));
}不要把客户编号改写成 /api/v1/orders/customer/C1001。客户编号描述的是筛选条件,不是某一笔订单的唯一地址。
defaultValue = "20" 表示调用方不传 limit 时使用 20。默认值不能代替边界校验,第 11 篇会补上完整校验。
3.4 部分更新:修改订单金额
@PatchMapping("/{orderNo}")
public ApiResponse<OrderView> update(
@PathVariable String orderNo,
@RequestBody CreateOrderRequest request
) {
// PATCH 允许只提交金额,客户编号为空时由业务层保留原值。
OrderView updated = orderService.update(
orderNo,
request.customerId(),
request.amount()
);
return ApiResponse.success(updated);
}创建与更新暂时复用了请求对象,但校验规则最终会通过分组区分。即使复用类型,Controller 仍要明确当前接口允许修改哪些字段。
第 4 步:统一响应只定义一次
demo-common 中的 ApiResponse 是所有成功响应的统一外壳:
public record ApiResponse<T>(
String code,
String message,
T data,
Instant timestamp
) {
// 成功码固定为 0,具体数据放在泛型 data 中。
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("0", "success", data, Instant.now());
}
// 错误响应复用同一外壳,避免每个接口定义不同字段。
public static <T> ApiResponse<T> failure(String code, String message) {
return new ApiResponse<>(code, message, null, Instant.now());
}
}不直接在 Controller 返回 OrderView,有三个直接好处:
- 成功和失败都有稳定字段。
- 客户端可以统一判断
code。 - 公共模块不必了解具体业务类型,
T可以是订单、列表或其他结构。
统一响应也有代价。对外提供第三方 API 时,很多团队会直接使用 HTTP 状态码和标准 ProblemDetail,不再包一层业务码。项目应选一种并保持稳定,不能每个模块各写一套。
第 5 步:启动并验证四个接口
在项目根目录执行:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar看到 Started DemoApplication 后再调用接口。
5.1 创建订单
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Content-Type: application/json" \
-d '{"customerId":"C1001","amount":99.00}'预期状态码是 201,响应中包含新订单号。记下这个订单号,下一步替换示例值。
5.2 查询单个订单
curl "http://localhost:8080/api/v1/orders/SOD7D5107CB2AA"如果返回的 customerId 和金额与创建时一致,说明路径变量和业务查询已经打通。
5.3 按客户查询
curl "http://localhost:8080/api/v1/orders?customerId=C1001&limit=20"响应中的 data 是数组。只返回 C1001 的订单,数量不超过 20。
5.4 更新金额
curl -i -X PATCH "http://localhost:8080/api/v1/orders/SOD7D5107CB2AA" \
-H "Content-Type: application/json" \
-d '{"amount":88.00}'预期 amount 变为 88.00,customerId 仍然是 C1001。
5.5 用自动化测试复验
在项目根目录执行:
mvn test测试会覆盖 URL 版本接口、参数绑定、更新流程和错误路径。看到 BUILD SUCCESS 才算通过。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
405 Method Not Allowed | 用 GET 调用 POST 接口,或 PATCH 写成 PUT | 对照接口契约检查 HTTP 方法 |
400 Bad Request | JSON 字段名错误、类型不匹配或请求体为空 | 检查 Content-Type、字段名和 JSON 语法 |
| 查询返回空数组 | 客户编号与创建时不一致 | 使用创建响应中的 customerId |
| PATCH 后客户编号变空 | 请求对象中的空值被直接写入 | 在业务层保留未提交字段的旧值 |
| 所有错误都返回 500 | 异常没有进入统一处理器 | 下一章检查 @RestControllerAdvice |
本章小结
Spring MVC 的参数绑定不是按个人习惯选择:
- 资源身份使用路径变量。
- 筛选条件使用查询参数。
- 复杂提交内容使用请求体。
- 部分更新使用
PATCH。
Controller 的职责是把 HTTP 输入转换成业务调用,再把业务结果转换成 HTTP 输出。它不应该保存订单、计算金额或手写数据库查询。
练习题
增加一个“按状态查询订单”的接口:
- 路径使用
GET /api/v1/orders。 - 查询参数为
status=CREATED。 - Controller 把字符串转换成
OrderStatus。 - 非法状态返回 400,而不是悄悄返回空数组。
上一节:Logback、MDC 与结构化日志
下一节:REST API 版本管理
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
