六个端点:状态码就是这道题的全部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.
这一页有什么On this page11
- 01 题面与 starterThe question and the starter code
- 02 实测:六个端点全返回 null,五个测试过了三个Measured: all six endpoints return null, and three of five tests pass
- 03 五个状态码,各自什么时候用Five status codes, and when each one is used
- 04 GET /api/orders 的可选过滤The optional filter on GET /api/orders
- 05 PATCH 端点:字符串转 enum 是唯一需要动脑的地方The PATCH endpoint: turning a string into an enum is the only part that needs thought
- 06 六个端点的完整实现The complete implementation of all six endpoints
- 07 五个测试怎么读How to read the five tests
- 08 测试之外的自检清单A self-check list for what the tests do not cover
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 独立写出六个端点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
审计实测: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.
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.
graphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.javagraphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java五个测试Five tests
graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java题面与 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.
graphql-federation-practice/README.mdgraphql-federation-practice/java-service/src/main/java/com/techflow/orders/controller/OrderController.java实测:六个端点全返回 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%.
五个状态码,各自什么时候用Five status codes, and when each one is used
| 状态码 | 语义 | 本题里哪个端点 | 怎么写 |
|---|---|---|---|
| 200 OK | 成功,有内容返回 | 三个 GET、PATCH | ResponseEntity.ok(body) |
| 201 Created | 创建成功 | POST | ResponseEntity.status(HttpStatus.CREATED).body(created) |
| 204 No Content | 成功,没有内容返回 | DELETE | ResponseEntity.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。
| Status | Meaning | Which endpoint here | How to write it |
|---|---|---|---|
| 200 OK | Success, with content | the three GETs and PATCH | ResponseEntity.ok(body) |
| 201 Created | Created something | POST | ResponseEntity.status(HttpStatus.CREATED).body(created) |
| 204 No Content | Success, with no content | DELETE | ResponseEntity.noContent().build() |
| 400 Bad Request | The request itself is invalid | POST (Bean Validation does it), PATCH (a bad status, you write this one) | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg) |
| 404 Not Found | The target does not exist | GET 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.
GET /api/orders 的可选过滤The optional filter on GET /api/orders
第一个端点的签名里有一个@RequestParam(required = false) String userId。这个参数是提示: 它要求你实现「可选过滤」。
?userId=123 传了 → 只返回那个用户的订单。 没传(userId 是 null)→ 返回全部。
注意要同时判断 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.
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:
valueOfis case sensitive.valueOf("shipped")throwsIllegalArgumentException. So calltoUpperCase()first.- An invalid value throws.
valueOf("FLYING")throwsIllegalArgumentException, 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.
六个端点的完整实现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:OrderStatus、Logger / LoggerFactory、MDC、HttpStatus、ResponseStatusException。 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.
五个测试怎么读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.
graphql-federation-practice/java-service/src/test/java/com/techflow/orders/OrderControllerTest.java测试之外的自检清单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.
动手做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.
POST /api/orders 成功创建了一个订单。 该返回哪个状态码,怎么写?
POST /api/orders created an order successfully. Which status code should it return, and how do you write that?
baseline 状态下六个端点全是 return null, 五个测试却通过了三个。为什么?
At the baseline all six endpoints are just return null, yet three of the five tests pass. Why?
五个空。第 2、4、5 个是这道题真正的得分点。
Five blanks. Numbers 2, 4 and 5 are where the credit in this question actually is.
六个端点一起写。业务逻辑全部调 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.
- 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
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
五个测试全过。但手动 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.
初学者常见的几种写法错误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.
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?ResponseEntity.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.① 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.
测试的
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.换一道题也能用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.
- 六个端点全 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.
- 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.
- EntityNotFoundException 交给 GlobalExceptionHandler,控制器里不要 catch。Let EntityNotFoundException reach GlobalExceptionHandler; do not catch it in the controller.
- 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.
- 测试用 @MockBean 替换了 service,所以完全不验证业务逻辑 —— 必须手动 curl 自检。The tests replace the service with @MockBean, so they check no business logic at all. Check it yourself with curl.