六个 Spring Boot REST 端点Six Spring Boot REST endpoints
题面The problem
先把要求读完,再动手。Read every requirement before you start.
给你 OrderService 的方法签名和五个测试。 自己搭一个 Spring Boot 项目,写出六个端点。不要打开源项目的 OrderController.java。
You are given the method signatures of OrderService and five tests. Set up a Spring Boot project yourself and write six endpoints. Do not open OrderController.java from the source project.
- Spring Boot 3.3 + Java 17,依赖 web / validation / actuator / testSpring Boot 3.3 + Java 17, with the web / validation / actuator / test dependencies
- 一个 @RestController,构造器注入 OrderServiceOne @RestController, with OrderService injected through the constructor
- GET /api/orders:?userId= 传了就按用户过滤,没传返回全部;200GET /api/orders: filter by user when ?userId= is given, return everything when it is not; 200
- GET /api/orders/{id}:200;找不到时由全局异常处理器给出 404(控制器不要 catch)GET /api/orders/{id}: 200; when nothing is found, the global exception handler answers 404 (do not catch it in the controller)
- GET /api/orders/user/{userId}:200GET /api/orders/user/{userId}: 200
- POST /api/orders:@Valid 校验请求体;成功返回 201 CreatedPOST /api/orders: validate the request body with @Valid; on success return 201 Created
- PATCH /api/orders/{id}/status:body 是 {"status":"..."};转成 OrderStatus;缺失或非法值返回 400;成功 200PATCH /api/orders/{id}/status: the body is {"status":"..."}; convert it to OrderStatus; a missing or invalid value returns 400; on success 200
- DELETE /api/orders/{id}:204 No ContentDELETE /api/orders/{id}: 204 No Content
- 六个端点都用 SLF4J 打日志,并带上 MDC 里的 correlationIdAll six endpoints log through SLF4J and carry the correlationId from MDC
- 自己写一个 CorrelationIdFilter:读 X-Correlation-ID 头,没有就生成 UUID,放进 MDC,finally 里清理Write your own CorrelationIdFilter: read the X-Correlation-ID header, generate a UUID when it is absent, put it in MDC, and clear it in finally
- 自己写 GlobalExceptionHandler:EntityNotFoundException → 404,MethodArgumentNotValidException → 400Write your own GlobalExceptionHandler: EntityNotFoundException → 404, MethodArgumentNotValidException → 400
预计 75 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 75 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区Workspace
这道题要 JVM 和 Maven。浏览器里没有 JVM,装不出来也不该装 —— 所以这里只给命令和期望输出。This one needs a JVM and Maven. There is no JVM in a browser and there should not be, so you get the commands and the expected output instead.
跑完自己对一遍期望输出,然后在下面打勾。这里不给假编辑器 —— 装个能跑的样子只会让你以为练过了。Compare the output yourself, then tick it off below. No fake editor here.
展开讲解Walkthrough
下面是《六个端点:状态码就是这道题的全部》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “六个端点:状态码就是这道题的全部” — the same content as in the course, not a rewritten summary. Expand it when you stall.
展开《六个端点:状态码就是这道题的全部》(8 段 · 约 18 分钟)Expand “六个端点:状态码就是这道题的全部” (8 sections · ~18 min)
完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 六个端点:状态码就是这道题的全部。
题面与 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.
参考答案Reference solution
提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.