DrillLab
第 14 / 17 节LESSON 14 / 17约 18 分钟~18 min

六个端点:状态码就是这道题的全部Six endpoints: the status codes are the whole task

五个测试只抓住两个错。另外三个端点全返回 null 也能过 —— 这一节讲怎么真的做对。The five tests catch only two mistakes. Three endpoints can return null and still pass. This lesson is about getting them actually right.

5 个练习5 exercisesFederation · 第 4 部分Federation · Part 4
这一页有什么On this page11
学完这节你会After this lesson you can
  • 独立写出六个端点Write all six endpoints without help
  • 说清 200 / 201 / 204 / 400 / 404 各在什么时候用Say when each of 200 / 201 / 204 / 400 / 404 is used
  • 解释为什么 return null 能骗过三个测试Explain why returning null fools three of the tests
  • 写出 PATCH 端点里字符串转 enum 的安全处理Write a safe string-to-enum conversion in the PATCH endpoint
这在考试里考什么What the exam does with this

审计实测:baseline 状态下六个端点全部 return null,五个测试通过了三个。只有 201 和 204 那两条抓住了错。这是整门考试「测试通过 ≠ 做对了」最夸张的一个实例。Measured during the audit: in the baseline all six endpoints return null, and three of the five tests pass. Only the 201 check and the 204 check catch a mistake. This is the most extreme example in the whole exam of tests passing while the code is wrong.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
graphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.java六个 TODOSix TODOs

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JavaOrderController.java源项目From source
1package com.techflow.orders.controller;
2
3import com.techflow.orders.dto.CreateOrderRequest;
4import com.techflow.orders.model.Order;
5import com.techflow.orders.service.OrderService;
6import jakarta.validation.Valid;
7import java.util.List;
8import java.util.Map;
9import org.springframework.http.ResponseEntity;
10import org.springframework.web.bind.annotation.DeleteMapping;
11import org.springframework.web.bind.annotation.GetMapping;
12import org.springframework.web.bind.annotation.PatchMapping;
13import org.springframework.web.bind.annotation.PathVariable;
14import org.springframework.web.bind.annotation.PostMapping;
15import org.springframework.web.bind.annotation.RequestBody;
16import org.springframework.web.bind.annotation.RequestParam;
17import org.springframework.web.bind.annotation.RestController;
18
19@RestController
20public class OrderController {
21 private final OrderService orderService;
22
23 public OrderController(OrderService orderService) {
24 this.orderService = orderService;
25 }
26
27 @GetMapping("/")
28 public ResponseEntity<Map<String, String>> getRoot() {
29 return ResponseEntity.ok(Map.of(
30 "message", "Welcome to TechFlow Order Service API",
31 "status", "running"
32 ));
33 }
34
35 @GetMapping("/api/orders")
36 public ResponseEntity<List<Order>> getAllOrders(
37 @RequestParam(required = false) String userId) {
38 // TODO: Implement REST endpoint with structured logging and request validation
39 return null;
40 }
41
42 @GetMapping("/api/orders/{id}")
43 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
44 // TODO: Implement GET endpoint for order by ID
45 return null;
46 }
47
48 @GetMapping("/api/orders/user/{userId}")
49 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
50 // TODO: Implement GET endpoint for orders by user ID
51 return null;
52 }
53
54 @PostMapping("/api/orders")
55 public ResponseEntity<Order> createOrder(
56 @Valid @RequestBody CreateOrderRequest request) {
57 // TODO: Implement POST endpoint with Bean Validation and proper HTTP status
58 return null;
59 }
60
61 @PatchMapping("/api/orders/{id}/status")
62 public ResponseEntity<Order> updateOrderStatus(
63 @PathVariable Long id,
64 @RequestBody Map<String, String> statusUpdate) {
65 // TODO: Implement PATCH endpoint for order status updates
66 return null;
67 }
68
69 @DeleteMapping("/api/orders/{id}")
70 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
71 // TODO: Implement DELETE endpoint
72 return null;
73 }
74}
Source: graphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.java
graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java五个测试Five tests
JavaOrderControllerTest.java源项目From source
1package com.techflow.orders;
2
3import static org.mockito.ArgumentMatchers.any;
4import static org.mockito.Mockito.when;
5import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
6import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
7import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
8import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
9import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
10
11import com.techflow.orders.controller.OrderController;
12import com.techflow.orders.dto.CreateOrderRequest;
13import com.techflow.orders.model.Order;
14import com.techflow.orders.model.OrderStatus;
15import com.techflow.orders.service.OrderService;
16import java.util.List;
17import org.junit.jupiter.api.Test;
18import org.springframework.beans.factory.annotation.Autowired;
19import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
20import org.springframework.boot.test.mock.mockito.MockBean;
21import org.springframework.http.MediaType;
22import org.springframework.test.web.servlet.MockMvc;
23
24@WebMvcTest(OrderController.class)
25class OrderControllerTest {
26 @Autowired
27 private MockMvc mockMvc;
28
29 @MockBean
30 private OrderService orderService;
31
32 @Test
33 void shouldGetAllOrders() throws Exception {
34 when(orderService.getAllOrders()).thenReturn(List.of());
35 mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
36 }
37
38 @Test
39 void shouldGetOrderById() throws Exception {
40 when(orderService.getOrderById(1L)).thenReturn(new Order());
41 mockMvc.perform(get("/api/orders/1")).andExpect(status().isOk());
42 }
43
44 @Test
45 void shouldCreateOrder() throws Exception {
46 when(orderService.createOrder(any(CreateOrderRequest.class))).thenReturn(new Order());
47
48 mockMvc.perform(post("/api/orders")
49 .contentType(MediaType.APPLICATION_JSON)
50 .content("""
51 {
52 "userId": "123",
53 "items": [
54 { "productId": "prod-789", "quantity": 2 }
55 ]
56 }
57 """))
58 .andExpect(status().isCreated());
59 }
60
61 @Test
62 void shouldUpdateOrderStatus() throws Exception {
63 when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED)).thenReturn(new Order());
64
65 mockMvc.perform(patch("/api/orders/1/status")
66 .contentType(MediaType.APPLICATION_JSON)
67 .content("{\"status\":\"SHIPPED\"}"))
68 .andExpect(status().isOk());
69 }
70
71 @Test
72 void shouldDeleteOrder() throws Exception {
73 mockMvc.perform(delete("/api/orders/1"))
74 .andExpect(status().isNoContent());
75 }
76}
Source: graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java
§01

题面与 starterThe question and the starter code

README 里 Task 2 的原文:

六个端点,六个 // TODO, 六个 return null业务逻辑一行都不用你写—— 全在 OrderService 里。

Task 2 in the README, word for word:

Six endpoints, six // TODO markers, six return null statements. You write no business logic at all — it is all sitting in OrderService.

TextREADME.md(Task 2 原文)README.md (the original Task 2 text)源项目From source
1## Task 2: Spring Boot REST Controller
2
3Implement REST endpoints in
4`java-service/src/main/java/com/techflow/orders/controller/OrderController.java`:
5
6- `GET /api/orders`
7- `GET /api/orders/{id}`
8- `GET /api/orders/user/{userId}`
9- `POST /api/orders`
10- `PATCH /api/orders/{id}/status`
11- `DELETE /api/orders/{id}`
12
13Use the provided `OrderService` for business logic.
Source: graphql-federation-practice/README.md
JavaOrderController.java(starter)OrderController.java (starter)源项目From source
1@RestController
2public class OrderController {
3 private final OrderService orderService;
4
5 public OrderController(OrderService orderService) {
6 this.orderService = orderService;
7 }
8
9 @GetMapping("/")
10 public ResponseEntity<Map<String, String>> getRoot() {
11 return ResponseEntity.ok(Map.of(
12 "message", "Welcome to TechFlow Order Service API",
13 "status", "running"
14 ));
15 }
16
17 @GetMapping("/api/orders")
18 public ResponseEntity<List<Order>> getAllOrders(
19 @RequestParam(required = false) String userId) {
20 // TODO: Implement REST endpoint with structured logging and request validation
21 return null;
22 }
23
24 @GetMapping("/api/orders/{id}")
25 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
26 // TODO: Implement GET endpoint for order by ID
27 return null;
28 }
29
30 @GetMapping("/api/orders/user/{userId}")
31 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
32 // TODO: Implement GET endpoint for orders by user ID
33 return null;
34 }
35
36 @PostMapping("/api/orders")
37 public ResponseEntity<Order> createOrder(
38 @Valid @RequestBody CreateOrderRequest request) {
39 // TODO: Implement POST endpoint with Bean Validation and proper HTTP status
40 return null;
41 }
42
43 @PatchMapping("/api/orders/{id}/status")
44 public ResponseEntity<Order> updateOrderStatus(
45 @PathVariable Long id,
46 @RequestBody Map<String, String> statusUpdate) {
47 // TODO: Implement PATCH endpoint for order status updates
48 return null;
49 }
50
51 @DeleteMapping("/api/orders/{id}")
52 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
53 // TODO: Implement DELETE endpoint
54 return null;
55 }
56}
Source: graphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.java
§02

实测:六个端点全返回 null,五个测试过了三个Measured: all six endpoints return null, and three of five tests pass

这是本项目最值得记住的一个事实。This is the single fact from this project most worth remembering.

审计时我在临时目录里跑了 baseline(原封不动的 starter):

为什么 return null 能过?因为 Spring 里,控制器方法返回 null 时, 框架认为「你已经自己处理完响应了」, 于是返回一个 200 OK + 空 body

而那三个通过的测试断言的是 status().isOk() —— 也就是 200。正好符合。

只有两条测试断言了非 200 的状态码 (isCreated() = 201、isNoContent() = 204), 所以只有它们抓住了错。

结论和 Node 那边一样,但更极端:三个端点完全没实现,测试却是绿的。如果你只看测试,会以为自己做完了 60%, 实际上是 0%。

During the audit I ran the baseline in a scratch copy — the starter, untouched:

Why does returning null pass? Because when a Spring controller method returns null, the framework assumes you already dealt with the response yourself, so it sends back a 200 OK with an empty body.

And the three tests that passed assert status().isOk() — that is, 200. Exact match.

Only two tests assert a status other than 200 (isCreated() = 201, isNoContent() = 204), so those two are the only ones that caught anything.

Same lesson as on the Node side, only worse: three endpoints are not implemented at all and the suite is green. Judging by tests alone you would think you were 60% done. You are at 0%.

Terminal本机实测(scratchpad 副本,未改动源项目)Measured locally (on a scratch copy; the source project was not touched)已跑通Verified
1$ mvn test # baseline,六个端点全是 return null
2
3[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
4[ERROR] OrderControllerTest.shouldCreateOrder:58 Status expected:<201> but was:<200>
5[ERROR] OrderControllerTest.shouldDeleteOrder:74 Status expected:<204> but was:<200>
6
7# 通过的三个:
8# ✓ shouldGetAllOrders 断言 isOk() → return null 也是 200
9# ✓ shouldGetOrderById 断言 isOk() → 同上
10# ✓ shouldUpdateOrderStatus 断言 isOk() → 同上
1$ mvn test # baseline: all six endpoints just return null
2
3[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
4[ERROR] OrderControllerTest.shouldCreateOrder:58 Status expected:<201> but was:<200>
5[ERROR] OrderControllerTest.shouldDeleteOrder:74 Status expected:<204> but was:<200>
6
7# The three that pass:
8# ✓ shouldGetAllOrders asserts isOk() → return null is a 200 too
9# ✓ shouldGetOrderById asserts isOk() → same
10# ✓ shouldUpdateOrderStatus asserts isOk() → same
§03

五个状态码,各自什么时候用Five status codes, and when each one is used

状态码语义本题里哪个端点怎么写
200 OK成功,有内容返回三个 GET、PATCHResponseEntity.ok(body)
201 Created创建成功POSTResponseEntity.status(HttpStatus.CREATED).body(created)
204 No Content成功,没有内容返回DELETEResponseEntity.noContent().build()
400 Bad Request请求本身不合法POST(Bean Validation 自动)、 PATCH(status 非法,要你写throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg)
404 Not Found目标不存在GET by id、PATCH、DELETE (全局处理器自动)不写 —— 让 service 的异常冒出去

201 和 204 是这道题的分水岭。它们也是最容易忘的 —— 因为ResponseEntity.ok() 用惯了, 手会自动打出来。

记一个判据:「有东西返回吗?」没有 → 204。 「是新造了一个资源吗?」是 → 201。

StatusMeaningWhich endpoint hereHow to write it
200 OKSuccess, with contentthe three GETs and PATCHResponseEntity.ok(body)
201 CreatedCreated somethingPOSTResponseEntity.status(HttpStatus.CREATED).body(created)
204 No ContentSuccess, with no contentDELETEResponseEntity.noContent().build()
400 Bad RequestThe request itself is invalidPOST (Bean Validation does it), PATCH (a bad status, you write this one)throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg)
404 Not FoundThe target does not existGET by id, PATCH, DELETE (the global handler does it)Write nothing — let the service exception bubble out

201 and 204 are the watershed of this paper. They are also the easiest to forget, because your hands type ResponseEntity.ok() out of habit.

One test to remember: “Is there anything to send back?” No → 204. “Did I just create a resource?” Yes → 201.

§04

GET /api/orders 的可选过滤The optional filter on GET /api/orders

第一个端点的签名里有一个@RequestParam(required = false) String userId这个参数是提示: 它要求你实现「可选过滤」。

?userId=123 传了 → 只返回那个用户的订单。 没传(userIdnull)→ 返回全部。

注意要同时判断 null 和空白。?userId= 这种写法会让 userId 是空字符串 而不是 null,用 isBlank() 一起挡掉。

TODO 原文写的是with structured logging and request validation—— 「request validation」指的就是这个判断。

The first endpoint’s signature contains a @RequestParam(required = false) String userId. That parameter is the hint: it is asking you to implement optional filtering.

?userId=123 was sent → return only that user’s orders. Not sent (userId is null) → return everything.

Check for blank as well as null. A request like ?userId= gives you an empty string, not null, so block both with isBlank().

The TODO reads with structured logging and request validation — that “request validation” is exactly this check.

Java第一个端点(参考答案)The first endpoint (reference answer)已跑通Verified
1@GetMapping("/api/orders")
2public ResponseEntity<List<Order>> getAllOrders(
3 @RequestParam(required = false) String userId) {
4 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
5
6 List<Order> orders = (userId == null || userId.isBlank())
7 ? orderService.getAllOrders()
8 : orderService.getOrdersByUserId(userId);
9
10 return ResponseEntity.ok(orders);
11}
§05

PATCH 端点:字符串转 enum 是唯一需要动脑的地方The PATCH endpoint: turning a string into an enum is the only part that needs thought

这个端点收 Map 而不是 DTO,所以没有 Bean Validation 保护。This endpoint takes a Map instead of a DTO, so Bean Validation does not protect it.

请求体是 {"status":"SHIPPED"}, 但 orderService.updateOrderStatus需要的是 OrderStatus 枚举。 所以必须转换。

OrderStatus.valueOf("SHIPPED") 能转, 但有两个坑:

  • valueOf 大小写敏感。valueOf("shipped") 会抛IllegalArgumentException。 所以要 toUpperCase()
  • 非法值会抛异常。valueOf("FLYING")IllegalArgumentException, 而全局处理器没有处理它—— 所以会变成 500。 但语义上这是客户端的错,应该是 400

这里是本题唯一该写 try/catch 的地方—— 因为要把一种异常转成不同的状态码。 用 ResponseStatusException 最省事: 它是 Spring 提供的「带状态码的异常」, 不需要额外的 handler。

还要挡住 status 缺失的情况。{} 这种 body 会让statusUpdate.get("status") 返回 null,null.trim() 直接 NPE → 500。 同样应该是 400。

这两条测试都没查。测试只发了合法的 SHIPPED。 但它们是明显的正确性问题 —— 人工 review 会看。

The request body is {"status":"SHIPPED"}, but orderService.updateOrderStatus wants an OrderStatus enum. So you have to convert.

OrderStatus.valueOf("SHIPPED") does the conversion, but it has two traps:

  • valueOf is case sensitive. valueOf("shipped") throws IllegalArgumentException. So call toUpperCase() first.
  • An invalid value throws. valueOf("FLYING") throws IllegalArgumentException, and the global handler does not handle that one — so it comes out as a 500. But this is the client’s mistake, so it should be a 400.

This is the one place in this paper where try/catch belongs — because you are turning one exception into a different status code. ResponseStatusException is the cheapest way: it is Spring’s built-in “exception carrying a status code”, and it needs no extra handler.

Block the missing-status case too. A body of {} makes statusUpdate.get("status") return null, and null.trim() is an NPE → 500. That should be a 400 as well.

Neither of these is tested. The tests only send a valid SHIPPED. But they are plain correctness bugs — a human reviewer will look.

JavaPATCH 端点(参考答案)The PATCH endpoint (reference answer)已跑通Verified
1@PatchMapping("/api/orders/{id}/status")
2public ResponseEntity<Order> updateOrderStatus(
3 @PathVariable Long id,
4 @RequestBody Map<String, String> statusUpdate) {
5 String raw = statusUpdate.get("status");
6 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
7 id, raw, correlationId());
8
9 if (raw == null || raw.isBlank()) {
10 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
11 }
12
13 final OrderStatus status;
14 try {
15 status = OrderStatus.valueOf(raw.trim().toUpperCase());
16 } catch (IllegalArgumentException ex) {
17 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
18 }
19
20 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
21}
§06

六个端点的完整实现The complete implementation of all six endpoints

审计实测:这样写之后 5 个测试全过,BUILD SUCCESS。Measured during the audit: with this code all 5 tests pass and the build reports BUILD SUCCESS.

注意最下面那个私有方法 correlationId() —— 把 MDC.get("correlationId")包一层,六个端点都能用,也让日志语句短一些。这种小重构在 review 里是加分项。

需要新增的 import:OrderStatusLogger / LoggerFactoryMDCHttpStatusResponseStatusException。 starter 里没有它们。

Notice the little private correlationId() method at the bottom. It wraps MDC.get("correlationId") so all six endpoints can use it and the log statements stay short. A small refactor like that scores points in review.

Imports you have to add: OrderStatus, Logger / LoggerFactory, MDC, HttpStatus, ResponseStatusException. None of them are in the starter.

JavaOrderController.java(完整参考答案,实测 5/5 通过)OrderController.java (the full reference answer, measured 5/5 passing)已跑通Verified
1@RestController
2public class OrderController {
3 private static final Logger logger = LoggerFactory.getLogger(OrderController.class);
4
5 private final OrderService orderService;
6
7 public OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping("/")
12 public ResponseEntity<Map<String, String>> getRoot() {
13 return ResponseEntity.ok(Map.of(
14 "message", "Welcome to TechFlow Order Service API",
15 "status", "running"
16 ));
17 }
18
19 @GetMapping("/api/orders")
20 public ResponseEntity<List<Order>> getAllOrders(
21 @RequestParam(required = false) String userId) {
22 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
23
24 // 可选过滤:?userId=123 收窄集合,而不是另开一个路由
25 List<Order> orders = (userId == null || userId.isBlank())
26 ? orderService.getAllOrders()
27 : orderService.getOrdersByUserId(userId);
28
29 return ResponseEntity.ok(orders);
30 }
31
32 @GetMapping("/api/orders/{id}")
33 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
34 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
35
36 // 找不到时 service 抛 EntityNotFoundException,
37 // 由 GlobalExceptionHandler 转成 404
38 return ResponseEntity.ok(orderService.getOrderById(id));
39 }
40
41 @GetMapping("/api/orders/user/{userId}")
42 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
43 logger.info("GET /api/orders/user/{} correlationId={}", userId, correlationId());
44 return ResponseEntity.ok(orderService.getOrdersByUserId(userId));
45 }
46
47 @PostMapping("/api/orders")
48 public ResponseEntity<Order> createOrder(
49 @Valid @RequestBody CreateOrderRequest request) {
50 logger.info("POST /api/orders userId={}, correlationId={}",
51 request.getUserId(), correlationId());
52
53 Order created = orderService.createOrder(request);
54
55 // 创建成功返回 201,不是 200
56 return ResponseEntity.status(HttpStatus.CREATED).body(created);
57 }
58
59 @PatchMapping("/api/orders/{id}/status")
60 public ResponseEntity<Order> updateOrderStatus(
61 @PathVariable Long id,
62 @RequestBody Map<String, String> statusUpdate) {
63 String raw = statusUpdate.get("status");
64 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
65 id, raw, correlationId());
66
67 if (raw == null || raw.isBlank()) {
68 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
69 }
70
71 final OrderStatus status;
72 try {
73 status = OrderStatus.valueOf(raw.trim().toUpperCase());
74 } catch (IllegalArgumentException ex) {
75 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
76 }
77
78 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
79 }
80
81 @DeleteMapping("/api/orders/{id}")
82 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
83 logger.info("DELETE /api/orders/{} correlationId={}", id, correlationId());
84
85 orderService.deleteOrder(id);
86
87 // 没有内容可返回 -> 204
88 return ResponseEntity.noContent().build();
89 }
90
91 /** CorrelationIdFilter 放在 MDC 里的 correlation id,用于串联日志 */
92 private String correlationId() {
93 return MDC.get("correlationId");
94 }
95}
1@RestController
2public class OrderController {
3 private static final Logger logger = LoggerFactory.getLogger(OrderController.class);
4
5 private final OrderService orderService;
6
7 public OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping("/")
12 public ResponseEntity<Map<String, String>> getRoot() {
13 return ResponseEntity.ok(Map.of(
14 "message", "Welcome to TechFlow Order Service API",
15 "status", "running"
16 ));
17 }
18
19 @GetMapping("/api/orders")
20 public ResponseEntity<List<Order>> getAllOrders(
21 @RequestParam(required = false) String userId) {
22 logger.info("GET /api/orders userId={}, correlationId={}", userId, correlationId());
23
24 // Optional filter: ?userId=123 narrows the set instead of adding a route
25 List<Order> orders = (userId == null || userId.isBlank())
26 ? orderService.getAllOrders()
27 : orderService.getOrdersByUserId(userId);
28
29 return ResponseEntity.ok(orders);
30 }
31
32 @GetMapping("/api/orders/{id}")
33 public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
34 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
35
36 // When it is not found the service throws EntityNotFoundException,
37 // and GlobalExceptionHandler turns that into a 404
38 return ResponseEntity.ok(orderService.getOrderById(id));
39 }
40
41 @GetMapping("/api/orders/user/{userId}")
42 public ResponseEntity<List<Order>> getOrdersByUserId(@PathVariable String userId) {
43 logger.info("GET /api/orders/user/{} correlationId={}", userId, correlationId());
44 return ResponseEntity.ok(orderService.getOrdersByUserId(userId));
45 }
46
47 @PostMapping("/api/orders")
48 public ResponseEntity<Order> createOrder(
49 @Valid @RequestBody CreateOrderRequest request) {
50 logger.info("POST /api/orders userId={}, correlationId={}",
51 request.getUserId(), correlationId());
52
53 Order created = orderService.createOrder(request);
54
55 // A successful create returns 201, not 200
56 return ResponseEntity.status(HttpStatus.CREATED).body(created);
57 }
58
59 @PatchMapping("/api/orders/{id}/status")
60 public ResponseEntity<Order> updateOrderStatus(
61 @PathVariable Long id,
62 @RequestBody Map<String, String> statusUpdate) {
63 String raw = statusUpdate.get("status");
64 logger.info("PATCH /api/orders/{}/status status={}, correlationId={}",
65 id, raw, correlationId());
66
67 if (raw == null || raw.isBlank()) {
68 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status is required");
69 }
70
71 final OrderStatus status;
72 try {
73 status = OrderStatus.valueOf(raw.trim().toUpperCase());
74 } catch (IllegalArgumentException ex) {
75 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown status: " + raw);
76 }
77
78 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
79 }
80
81 @DeleteMapping("/api/orders/{id}")
82 public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
83 logger.info("DELETE /api/orders/{} correlationId={}", id, correlationId());
84
85 orderService.deleteOrder(id);
86
87 // Nothing to return -> 204
88 return ResponseEntity.noContent().build();
89 }
90
91 /** The correlation id CorrelationIdFilter put in the MDC, to tie logs together */
92 private String correlationId() {
93 return MDC.get("correlationId");
94 }
95}
Terminal审计时的真实输出(参考解法)The real output from the audit (with the reference answer)已跑通Verified
1$ mvn test
2
3[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
4[INFO] BUILD SUCCESS
5[INFO] Total time: 18.694 s
6
7# 日志里能看到 correlationId 正常注入:
8INFO c.t.orders.controller.OrderController : DELETE /api/orders/1 correlationId=0e157516-...
9INFO c.t.orders.controller.OrderController : PATCH /api/orders/1/status status=SHIPPED, correlationId=7b441d8d-...
10INFO c.t.orders.controller.OrderController : POST /api/orders userId=123, correlationId=3bf0f9c5-...
1$ mvn test
2
3[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
4[INFO] BUILD SUCCESS
5[INFO] Total time: 18.694 s
6
7# The logs show correlationId being injected correctly:
8INFO c.t.orders.controller.OrderController : DELETE /api/orders/1 correlationId=0e157516-...
9INFO c.t.orders.controller.OrderController : PATCH /api/orders/1/status status=SHIPPED, correlationId=7b441d8d-...
10INFO c.t.orders.controller.OrderController : POST /api/orders userId=123, correlationId=3bf0f9c5-...
§07

五个测试怎么读How to read the five tests

@WebMvcTest(OrderController.class) 的意思是「只启动 Web 层,只加载这一个控制器」。 不会启动完整的 Spring 应用、不会加载 service 和 repository。

@MockBean OrderService 用一个假的 OrderService 替换真的。 所以 when(orderService.getAllOrders()).thenReturn(List.of())这一行是在说「如果被调用了,就返回空列表」。

这意味着测试完全不验证业务逻辑—— 它只验证「路由对不对、状态码对不对」。 所以:

  • ✅ 能抓住:路径写错、HTTP 方法写错、状态码写错。
  • ❌ 抓不到:调错了 service 方法 (比如 GET by id 里调了 getAllOrders)、 可选过滤没实现、PATCH 的非法值没挡、日志没打。

MockMvc 是「不起真实服务器的 HTTP 客户端」—— 它直接把请求喂给 Spring 的 DispatcherServlet, 所以跑得很快(整个测试 2 秒)。

注意 shouldUpdateOrderStatus 那条:when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED))指定了确切的枚举值。 如果你的转换写错了(比如没 toUpperCase而客户端传的是小写),mock 不匹配, 会返回 null → 依然是 200 → 测试还是过又一个抓不到的地方。

@WebMvcTest(OrderController.class) means “boot the web layer only, and load just this one controller”. No full Spring application, no service, no repository.

@MockBean OrderService swaps the real service for a fake one. So the line when(orderService.getAllOrders()).thenReturn(List.of()) says “if this gets called, hand back an empty list”.

Which means the tests verify no business logic whatsoever — they only verify “is the route right, is the status code right”. So:

  • ✅ Caught: a wrong path, a wrong HTTP method, a wrong status code.
  • ❌ Missed: calling the wrong service method (GET by id calling getAllOrders, say), optional filtering not implemented, PATCH not blocking bad values, no logging at all.

MockMvc is an HTTP client that never starts a real server — it feeds requests straight into Spring’s DispatcherServlet, which is why it is fast (two seconds for the whole suite).

Look closely at shouldUpdateOrderStatus: when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED)) pins the exact enum value. If your conversion is wrong (no toUpperCase while the client sends lowercase), the mock does not match, it returns null → still a 200 → the test still passes. One more thing it cannot catch.

JavaOrderControllerTest.java(全文,PROVIDED)OrderControllerTest.java (full file, PROVIDED)源项目From source
1@WebMvcTest(OrderController.class)
2class OrderControllerTest {
3 @Autowired
4 private MockMvc mockMvc;
5
6 @MockBean
7 private OrderService orderService;
8
9 @Test
10 void shouldGetAllOrders() throws Exception {
11 when(orderService.getAllOrders()).thenReturn(List.of());
12 mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
13 }
14
15 @Test
16 void shouldGetOrderById() throws Exception {
17 when(orderService.getOrderById(1L)).thenReturn(new Order());
18 mockMvc.perform(get("/api/orders/1")).andExpect(status().isOk());
19 }
20
21 @Test
22 void shouldCreateOrder() throws Exception {
23 when(orderService.createOrder(any(CreateOrderRequest.class))).thenReturn(new Order());
24
25 mockMvc.perform(post("/api/orders")
26 .contentType(MediaType.APPLICATION_JSON)
27 .content("""
28 {
29 "userId": "123",
30 "items": [
31 { "productId": "prod-789", "quantity": 2 }
32 ]
33 }
34 """))
35 .andExpect(status().isCreated());
36 }
37
38 @Test
39 void shouldUpdateOrderStatus() throws Exception {
40 when(orderService.updateOrderStatus(1L, OrderStatus.SHIPPED)).thenReturn(new Order());
41
42 mockMvc.perform(patch("/api/orders/1/status")
43 .contentType(MediaType.APPLICATION_JSON)
44 .content("{\"status\":\"SHIPPED\"}"))
45 .andExpect(status().isOk());
46 }
47
48 @Test
49 void shouldDeleteOrder() throws Exception {
50 mockMvc.perform(delete("/api/orders/1"))
51 .andExpect(status().isNoContent());
52 }
53}
Source: graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java
§08

测试之外的自检清单A self-check list for what the tests do not cover

因为测试覆盖很弱,所以必须自己补一遍。mvn spring-boot:run 起服务,然后用 curl:

第 4 条和第 5 条测试完全没覆盖, 但它们是这个端点该有的行为。

Test coverage here is weak, so you have to fill the gap by hand. Start the service with mvn spring-boot:run, then reach for curl:

Items 4 and 5 are not covered by any test, yet they are behaviour these endpoints are supposed to have.

Terminal手动自检Checking it by hand已跑通Verified
1cd java-service
2mvn spring-boot:run # 起在 8080
3
4# 1. 全部 + 可选过滤(测试没覆盖过滤)
5curl -s localhost:8080/api/orders
6curl -s "localhost:8080/api/orders?userId=123" # 应该只返回 user 123 的
7
8# 2. 单个存在 vs 不存在(测试没覆盖 404)
9curl -i -s localhost:8080/api/orders/1 # 200 + JSON
10curl -i -s localhost:8080/api/orders/999 # 404 + { timestamp, status, message }
11
12# 3. 创建(应该是 201)
13curl -i -s -X POST localhost:8080/api/orders \
14 -H 'Content-Type: application/json' \
15 -d '{"userId":"123","items":[{"productId":"prod-789","quantity":2}]}'
16
17# 4. 创建时校验失败(应该是 400,测试没覆盖)
18curl -i -s -X POST localhost:8080/api/orders \
19 -H 'Content-Type: application/json' \
20 -d '{"userId":"","items":[]}'
21
22# 5. PATCH 非法状态(应该是 400 而不是 500,测试没覆盖)
23curl -i -s -X PATCH localhost:8080/api/orders/1/status \
24 -H 'Content-Type: application/json' -d '{"status":"FLYING"}'
25
26# 6. 删除(应该是 204,空 body)
27curl -i -s -X DELETE localhost:8080/api/orders/1
28
29# 7. correlation id 透传(响应头里应该有同一个 id)
30curl -i -s -H 'X-Correlation-ID: my-trace-1' localhost:8080/api/orders
1cd java-service
2mvn spring-boot:run # starts on 8080
3
4# 1. All of them, plus the optional filter (no test covers the filter)
5curl -s localhost:8080/api/orders
6curl -s "localhost:8080/api/orders?userId=123" # should return only user 123
7
8# 2. One that exists vs one that does not (no test covers the 404)
9curl -i -s localhost:8080/api/orders/1 # 200 + JSON
10curl -i -s localhost:8080/api/orders/999 # 404 + { timestamp, status, message }
11
12# 3. Create (should be 201)
13curl -i -s -X POST localhost:8080/api/orders \
14 -H 'Content-Type: application/json' \
15 -d '{"userId":"123","items":[{"productId":"prod-789","quantity":2}]}'
16
17# 4. Create with validation failing (should be 400, no test covers it)
18curl -i -s -X POST localhost:8080/api/orders \
19 -H 'Content-Type: application/json' \
20 -d '{"userId":"","items":[]}'
21
22# 5. PATCH with an unknown status (should be 400 not 500, no test covers it)
23curl -i -s -X PATCH localhost:8080/api/orders/1/status \
24 -H 'Content-Type: application/json' -d '{"status":"FLYING"}'
25
26# 6. Delete (should be 204 with an empty body)
27curl -i -s -X DELETE localhost:8080/api/orders/1
28
29# 7. correlation id passed through (the same id should be in the response header)
30curl -i -s -H 'X-Correlation-ID: my-trace-1' localhost:8080/api/orders
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1认出来Spot itPOST 创建成功该返回什么What a successful POST should return

POST /api/orders 成功创建了一个订单。 该返回哪个状态码,怎么写?

POST /api/orders created an order successfully. Which status code should it return, and how do you write that?

先选一个选项Pick an option first
L1认出来Spot it为什么 return null 能骗过三个测试Why return null fools three of the tests

baseline 状态下六个端点全是 return null, 五个测试却通过了三个。为什么?

At the baseline all six endpoints are just return null, yet three of the five tests pass. Why?

先选一个选项Pick an option first
L2填空Fill the blanks补全三个关键端点的状态码与调用Fill in the status codes and calls of three key endpoints

五个空。第 2、4、5 个是这道题真正的得分点。

Five blanks. Numbers 2, 4 and 5 are where the credit in this question actually is.

JAVAOrderController.java5 个空5 blanks
1@GetMapping("/api/orders/{id}")
2public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
3 // 找不到时 service 抛 EntityNotFoundException,交给全局处理器
4 return ResponseEntity.(orderService.getOrderById(id));
5}
6
7@PostMapping("/api/orders")
8public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
9 Order created = orderService.createOrder(request);
10 return ResponseEntity.status(HttpStatus.).body(created);
11}
12
13@PatchMapping("/api/orders/{id}/status")
14public ResponseEntity<Order> updateOrderStatus(
15 @PathVariable Long id,
16 @RequestBody Map<String, String> statusUpdate) {
17 String raw = statusUpdate.get("status");
18 if (raw == null || raw.isBlank()) {
19 throw new ResponseStatusException(HttpStatus., "status is required");
20 }
21 OrderStatus status = OrderStatus.(raw.trim().toUpperCase());
22 return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
23}
24
25@DeleteMapping("/api/orders/{id}")
26public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
27 orderService.deleteOrder(id);
28 return ResponseEntity.().build();
29}
把 5 个空都填上才能检查(还差 5 个)Fill all 5 blanks to check (5 to go)
L3写整块Write a block不看答案,自己写出全部六个端点Write all six endpoints yourself, without looking at the answer

六个端点一起写。业务逻辑全部调 orderService, 你负责选对状态码、处理可选参数、转 enum、打日志。

Write all six endpoints. Every piece of business logic goes through orderService; your job is picking the right status codes, handling the optional parameter, converting the enum, and logging.

要求Requirements
  • GET /api/orders:?userId= 传了就按用户过滤,没传返回全部;200GET /api/orders: filter by user when ?userId= is given, otherwise return everything; 200
  • GET /api/orders/{id}:200;不要 try/catch,让 404 由全局处理器给出GET /api/orders/{id}: 200; no try/catch, let the global handler produce the 404
  • GET /api/orders/user/{userId}:200GET /api/orders/user/{userId}: 200
  • POST /api/orders:201 CreatedPOST /api/orders: 201 Created
  • PATCH /api/orders/{id}/status:把 body 里的字符串转成 OrderStatus;缺失或非法值返回 400;成功 200PATCH /api/orders/{id}/status: convert the string in the body into an OrderStatus; return 400 when it is missing or invalid; 200 on success
  • DELETE /api/orders/{id}:204 No ContentDELETE /api/orders/{id}: 204 No Content
  • 六个端点都用 logger.info 打日志,并带上 MDC 里的 correlationIdAll six endpoints log with logger.info and include the correlationId from the MDC
JavaOrderController.java
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

L3Debug LabDebug LabDebug Lab · 查一个不存在的订单,返回了 200Debug Lab · Asking for an order that does not exist returns 200

五个测试全过。但手动 curl 一个不存在的 id, 得到 200 和一个空 body。期望是 404 加一段 JSON。

All five tests pass. But curl an id that does not exist by hand and you get a 200 with an empty body. It should be a 404 with a piece of JSON.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
$ curl -i -s localhost:8080/api/orders/999 HTTP/1.1 200 Content-Length: 0 # 期望: # HTTP/1.1 404 # { "timestamp": "...", "status": 404, "message": "Order not found with id: 999" } # mvn test:Tests run: 5, Failures: 0 ← 测试全过!$ curl -i -s localhost:8080/api/orders/999 HTTP/1.1 200 Content-Length: 0 # Expected: # HTTP/1.1 404 # { "timestamp": "...", "status": 404, "message": "Order not found with id: 999" } # mvn test: Tests run: 5, Failures: 0 ← every test passes!
Java有问题的实现The broken implementation示意Illustrative
1@GetMapping("/api/orders/{id}")
2public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
3 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
4 try {
5 return ResponseEntity.ok(orderService.getOrderById(id));
6 } catch (EntityNotFoundException ex) {
7 return null;
8 }
9}
1@GetMapping("/api/orders/{id}")
2public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
3 logger.info("GET /api/orders/{} correlationId={}", id, correlationId());
4 try {
5 return ResponseEntity.ok(orderService.getOrderById(id));
6 } catch (EntityNotFoundException ex) {
7 return null;
8 }
9}
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

Java示意Illustrative
1// ✗ POST 用了 ok()
2return ResponseEntity.ok(orderService.createOrder(request));
1// ✗ POST using ok()
2return ResponseEntity.ok(orderService.createOrder(request));
创建资源应该返回 201 Created。 这是被测试抓住的两个错之一:Status expected:<201> but was:<200>
ResponseEntity.ok() 是肌肉记忆, 写 REST 时要专门停一下问「这是创建吗?」
Creating a resource should return 201 Created. This is one of the two mistakes the tests catch: Status expected:<201> but was:<200>.
ResponseEntity.ok() is the one you type without thinking. When you write REST, stop and ask: is this call creating something?
Java示意Illustrative
1// ✗ DELETE 返回了 200
2orderService.deleteOrder(id);
3return ResponseEntity.ok().build();
1// ✗ DELETE returning 200
2orderService.deleteOrder(id);
3return ResponseEntity.ok().build();
删除成功没有内容可返回,标准是 204 No ContentResponseEntity.noContent().build()
判据很简单:deleteOrder 返回void,那就是 204。
A successful delete has no content to return, so the standard answer is 204 No Content: ResponseEntity.noContent().build().
The rule is simple: deleteOrder returns void, so the endpoint returns 204.
Java示意Illustrative
1// ✗ PATCH 直接 valueOf,没挡非法值
2OrderStatus status = OrderStatus.valueOf(statusUpdate.get("status"));
3return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
1// ✗ PATCH calling valueOf directly, with no guard against invalid values
2OrderStatus status = OrderStatus.valueOf(statusUpdate.get("status"));
3return ResponseEntity.ok(orderService.updateOrderStatus(id, status));
两个问题:
① body 是 {}get 返回 null,valueOf(null) 抛 NPE → 500。
② 传 "shipped"(小写)或"FLYING" 时抛IllegalArgumentException → 500。
两种都该是 400测试查不到(它只发合法的 SHIPPED), 但这是明显的正确性问题。
Two problems here:
1. When the body is {}, get returns null, and valueOf(null) throws an NPE, which becomes a 500.
2. Sending "shipped" in lower case, or "FLYING", throws IllegalArgumentException, which also becomes a 500.
Both cases should be 400. The tests do not check this, because they only send the valid value SHIPPED. It is still plainly wrong.
Java示意Illustrative
1// ✗ 忽略了可选的 userId 参数
2@GetMapping("/api/orders")
3public ResponseEntity<List<Order>> getAllOrders(
4 @RequestParam(required = false) String userId) {
5 return ResponseEntity.ok(orderService.getAllOrders()); // userId 白收了
6}
1// ✗ ignoring the optional userId parameter
2@GetMapping("/api/orders")
3public ResponseEntity<List<Order>> getAllOrders(
4 @RequestParam(required = false) String userId) {
5 return ResponseEntity.ok(orderService.getAllOrders()); // userId accepted for nothing
6}
签名里有这个参数就是在要求你用它。 TODO 原文还写了 request validation
测试的 get("/api/orders") 不带参数, 所以抓不到。 但人工 review 会看到「收了一个参数却没用」。
A parameter in the signature is a request to use it. The TODO text also says request validation.
The test calls get("/api/orders") with no parameter, so it cannot catch this. But a human reviewer will see a parameter that is accepted and then ignored.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

创建成功Something was created successfully
201 Created
service 方法返回 voidThe service method returns void
204 No Content + .build()
项目里有全局异常处理器The project has a global exception handler
别 try/catch,让异常冒出去No try/catch; let the exception travel up
要把某异常转成不同状态码You need one exception to map to a different status code
唯一该 try/catch 的场合,用 ResponseStatusExceptionThe one place try/catch belongs; use ResponseStatusException
收 Map 而不是 DTO 的端点An endpoint that takes a Map instead of a DTO
没有 Bean Validation 保护,自己挡 null 和非法值Bean Validation does not protect it; reject null and invalid values yourself
签名里有个没用到的参数The signature has a parameter nothing uses
那是提示:它要求你实现某个功能It is a hint: you are being asked to implement that feature
这节的要点What to take away
  1. 六个端点全 return null 也能过 3/5 测试 —— Spring 里返回 null 会给出 200 + 空 body。All six endpoints can return null and still pass 3 of 5 tests, because in Spring a null return produces 200 with an empty body.
  2. 201 Created 给 POST,204 No Content 给 DELETE,这是被测试抓住的两个点。201 Created for POST, 204 No Content for DELETE. These are the two points the tests do catch.
  3. EntityNotFoundException 交给 GlobalExceptionHandler,控制器里不要 catch。Let EntityNotFoundException reach GlobalExceptionHandler; do not catch it in the controller.
  4. PATCH 收 Map 没有校验保护:null 和非法枚举值都要自己挡成 400,valueOf 大小写敏感。The PATCH endpoint takes a Map, so nothing validates it: turn null and invalid enum values into 400 yourself, and remember valueOf is case sensitive.
  5. 测试用 @MockBean 替换了 service,所以完全不验证业务逻辑 —— 必须手动 curl 自检。The tests replace the service with @MockBean, so they check no business logic at all. Check it yourself with curl.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises5 个,就在这一页上面 —— 别攒着最后一起做5 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson两道书面题:延迟传播与生产配置The two written questions: how delay spreads, and production configuration
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 先看懂给你的东西:Spring 的几个注解和一条请求链路Understand what you are given: a few Spring annotations and the path one request takes